diff --git a/Sources/Rownd/Models/Context/ReSwiftObserver.swift b/Sources/Rownd/Models/Context/ReSwiftObserver.swift index f34fa9a..ac5e8f4 100644 --- a/Sources/Rownd/Models/Context/ReSwiftObserver.swift +++ b/Sources/Rownd/Models/Context/ReSwiftObserver.swift @@ -28,7 +28,6 @@ private func dispatchToMainActor( state: S, work: @escaping @MainActor (T, S) -> Void ) { - // Use DispatchQueue.main.async for FIFO ordering, then Task for @MainActor isolation DispatchQueue.main.async { [weak instance] in guard let instance = instance else { return } Task { @MainActor in @@ -40,19 +39,17 @@ private func dispatchToMainActor( // MARK: - ObservableState /// Observable wrapper for ReSwift state slices that publishes changes to SwiftUI. -/// Uses @MainActor to ensure all @Published property access is thread-safe. /// /// ## Thread Safety -/// ReSwift may call `newState(state:)` from any thread. This class uses @MainActor -/// isolation to ensure all @Published property access occurs on the main thread, -/// preventing crashes in swift_retain when accessing Combine's Published wrapper. +/// ReSwift may call `newState(state:)` from any thread. State mutations are dispatched +/// through `dispatchToMainActor` to ensure all `@Published` property access occurs on the +/// main thread, preventing crashes from concurrent access to Combine's Published wrapper. /// /// ## State Update Ordering /// State updates are dispatched through DispatchQueue.main.async to maintain FIFO ordering, /// then processed on the MainActor. While this preserves ordering of dispatch calls, /// the actual property updates occur asynchronously. For most SwiftUI use cases this is /// acceptable since SwiftUI will render the final state. -@MainActor public class ObservableState: ObservableObject, StoreSubscriber, ObservableSubscription { @@ -86,23 +83,21 @@ public class ObservableState: ObservableObject, StoreSubscriber, Ob } deinit { - // Note: deinit is nonisolated even for @MainActor classes. // ReSwift's SubscriptionBox holds a weak reference to subscribers, // so cleanup happens automatically when this object is deallocated. } - /// Called by ReSwift when state changes. This method is nonisolated because - /// ReSwift may call it from any thread. Updates are dispatched to MainActor - /// via DispatchQueue.main to maintain FIFO ordering. + /// Called by ReSwift when state changes. ReSwift may call this from any thread. + /// Updates are dispatched to MainActor via DispatchQueue.main to maintain FIFO ordering + /// and ensure @Published property access is thread-safe. nonisolated public func newState(state: T) { dispatchToMainActor(self, state: state) { instance, newState in instance.applyStateUpdate(newState) } } - /// Applies the state update on MainActor. Separated from newState to keep - /// the dispatch logic clean and enable subclass overrides. - fileprivate func applyStateUpdate(_ state: T) { + /// Applies the state update. Must be called on MainActor (enforced by dispatchToMainActor). + @MainActor fileprivate func applyStateUpdate(_ state: T) { guard current != state else { return } let old = current if let animation = animation { @@ -150,7 +145,7 @@ public class ObservableThrottledState: ObservableState { } } - fileprivate func applyThrottledStateUpdate(_ state: T) { + @MainActor fileprivate func applyThrottledStateUpdate(_ state: T) { guard current != state else { return } if let animation = animation { withAnimation(animation) { @@ -164,7 +159,6 @@ public class ObservableThrottledState: ObservableState { private let objectThrottled = PassthroughSubject() } -@MainActor public class ObservableDerivedState: ObservableObject, StoreSubscriber, ObservableSubscription { @@ -203,7 +197,6 @@ public class ObservableDerivedState: Obse } deinit { - // Note: deinit is nonisolated even for @MainActor classes. // ReSwift's SubscriptionBox holds a weak reference to subscribers, // so cleanup happens automatically when this object is deallocated. } @@ -214,7 +207,7 @@ public class ObservableDerivedState: Obse } } - fileprivate func applyStateUpdate(_ original: Original) { + @MainActor fileprivate func applyStateUpdate(_ original: Original) { let old = current objectWillChange.send(ChangeSubject(old: old, new: current)) @@ -267,7 +260,7 @@ public class ObservableDerivedThrottledState( select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil ) -> ObservableState { ObservableState(select: selector, animation: animation) } - @MainActor public func subscribe( select selector: @escaping (RowndState) -> (Original), transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil @@ -297,7 +288,6 @@ extension Store where State == RowndState { ObservableDerivedState(select: selector, transform: transform, animation: animation) } - @MainActor public func subscribeThrottled( select selector: @escaping (RowndState) -> (T), throttleInMs: Int = 350, animation: SwiftUI.Animation? = nil @@ -305,7 +295,6 @@ extension Store where State == RowndState { ObservableThrottledState(select: selector, animation: animation, throttleInMs: throttleInMs) } - @MainActor public func subscribeThrottled( select selector: @escaping (RowndState) -> (Original), transform: @escaping (Original) -> Derived, throttleInMs: Int = 350,