@@ -10,17 +10,49 @@ import Foundation
1010import ReSwift
1111import SwiftUI
1212
13- // MARK: - Main Thread Dispatch Helper
14-
15- /// Helper to centralize main-thread dispatch with weak self handling.
16- /// Reduces duplication and ensures consistent patterns across observable state types.
17- private func dispatchOnMain< T: AnyObject > ( _ instance: T , execute work: @escaping ( T ) -> Void ) {
13+ // MARK: - Main Actor Dispatch Helper
14+
15+ /// Dispatches work to the MainActor from a nonisolated context.
16+ ///
17+ /// Uses `DispatchQueue.main.async` to preserve FIFO ordering of state updates,
18+ /// then hops into a `@MainActor` Task for proper isolation. This prevents the
19+ /// ordering issues that can occur with unstructured Task spawning under
20+ /// high-frequency state changes.
21+ ///
22+ /// - Parameters:
23+ /// - instance: The object to operate on (captured weakly)
24+ /// - state: The state value to process
25+ /// - work: The MainActor-isolated work to perform
26+ private func dispatchToMainActor< T: AnyObject , S> (
27+ _ instance: T ,
28+ state: S ,
29+ work: @escaping @MainActor ( T , S ) -> Void
30+ ) {
31+ // Use DispatchQueue.main.async for FIFO ordering, then Task for @MainActor isolation
1832 DispatchQueue . main. async { [ weak instance] in
1933 guard let instance = instance else { return }
20- work ( instance)
34+ Task { @MainActor in
35+ work ( instance, state)
36+ }
2137 }
2238}
2339
40+ // MARK: - ObservableState
41+
42+ /// Observable wrapper for ReSwift state slices that publishes changes to SwiftUI.
43+ /// Uses @MainActor to ensure all @Published property access is thread-safe.
44+ ///
45+ /// ## Thread Safety
46+ /// ReSwift may call `newState(state:)` from any thread. This class uses @MainActor
47+ /// isolation to ensure all @Published property access occurs on the main thread,
48+ /// preventing crashes in swift_retain when accessing Combine's Published wrapper.
49+ ///
50+ /// ## State Update Ordering
51+ /// State updates are dispatched through DispatchQueue.main.async to maintain FIFO ordering,
52+ /// then processed on the MainActor. While this preserves ordering of dispatch calls,
53+ /// the actual property updates occur asynchronously. For most SwiftUI use cases this is
54+ /// acceptable since SwiftUI will render the final state.
55+ @MainActor
2456public class ObservableState < T: Hashable > : ObservableObject , StoreSubscriber , ObservableSubscription
2557{
2658
@@ -42,43 +74,45 @@ public class ObservableState<T: Hashable>: ObservableObject, StoreSubscriber, Ob
4274
4375 public func subscribe( ) {
4476 guard !isSubscribed else { return }
45- // Capture selector directly to avoid retaining self in the transform closure
4677 let selector = self . selector
47- dispatchOnMain ( self ) { instance in
48- guard !instance. isSubscribed else { return }
49- Context . currentContext. store. subscribe (
50- instance, transform: { $0. select ( selector) } )
51- instance. isSubscribed = true
52- }
78+ Context . currentContext. store. subscribe ( self , transform: { $0. select ( selector) } )
79+ isSubscribed = true
5380 }
5481
5582 func unsubscribe( ) {
5683 guard isSubscribed else { return }
57- dispatchOnMain ( self ) { instance in
58- guard instance. isSubscribed else { return }
59- Context . currentContext. store. unsubscribe ( instance)
60- instance. isSubscribed = false
61- }
84+ Context . currentContext. store. unsubscribe ( self )
85+ isSubscribed = false
6286 }
6387
6488 deinit {
65- unsubscribe ( )
89+ // Note: deinit is nonisolated even for @MainActor classes.
90+ // ReSwift's SubscriptionBox holds a weak reference to subscribers,
91+ // so cleanup happens automatically when this object is deallocated.
6692 }
6793
68- public func newState( state: T ) {
69- // All @Published property access must happen on main thread
70- dispatchOnMain ( self ) { instance in
71- guard instance. current != state else { return }
72- let old = instance. current
73- if let animation = instance. animation {
74- withAnimation ( animation) {
75- instance. current = state
76- }
77- } else {
78- instance. current = state
94+ /// Called by ReSwift when state changes. This method is nonisolated because
95+ /// ReSwift may call it from any thread. Updates are dispatched to MainActor
96+ /// via DispatchQueue.main to maintain FIFO ordering.
97+ nonisolated public func newState( state: T ) {
98+ dispatchToMainActor ( self , state: state) { instance, newState in
99+ instance. applyStateUpdate ( newState)
100+ }
101+ }
102+
103+ /// Applies the state update on MainActor. Separated from newState to keep
104+ /// the dispatch logic clean and enable subclass overrides.
105+ fileprivate func applyStateUpdate( _ state: T ) {
106+ guard current != state else { return }
107+ let old = current
108+ if let animation = animation {
109+ withAnimation ( animation) {
110+ current = state
79111 }
80- instance. objectDidChange. send ( DidChangeSubject ( old: old, new: instance. current) )
112+ } else {
113+ current = state
81114 }
115+ objectDidChange. send ( DidChangeSubject ( old: old, new: current) )
82116 }
83117
84118 public let objectDidChange = PassthroughSubject < DidChangeSubject < T > , Never > ( )
@@ -101,30 +135,36 @@ public class ObservableThrottledState<T: Hashable>: ObservableState<T> {
101135
102136 objectThrottled
103137 . throttle ( for: . milliseconds( throttleInMs) , scheduler: DispatchQueue . main, latest: true )
104- . sink { [ weak self] in self ? . current = $0 }
138+ . sink { [ weak self] in
139+ guard let self = self else { return }
140+ let old = self . current
141+ self . current = $0
142+ self . objectDidChange. send ( DidChangeSubject ( old: old, new: self . current) )
143+ }
105144 . store ( in: & cancellables)
106145 }
107146
108- override public func newState( state: T ) {
109- // All @Published property access must happen on main thread to avoid crashes
110- // in swift_retain when accessing Combine's Published wrapper from background threads
111- dispatchOnMain ( self ) { instance in
112- guard instance. current != state else { return }
113- let old = instance. current
114- if let animation = instance. animation {
115- withAnimation ( animation) {
116- instance. objectThrottled. send ( state)
117- }
118- } else {
119- instance. objectThrottled. send ( state)
147+ nonisolated override public func newState( state: T ) {
148+ dispatchToMainActor ( self , state: state) { instance, newState in
149+ instance. applyThrottledStateUpdate ( newState)
150+ }
151+ }
152+
153+ fileprivate func applyThrottledStateUpdate( _ state: T ) {
154+ guard current != state else { return }
155+ if let animation = animation {
156+ withAnimation ( animation) {
157+ objectThrottled. send ( state)
120158 }
121- instance. objectDidChange. send ( DidChangeSubject ( old: old, new: instance. current) )
159+ } else {
160+ objectThrottled. send ( state)
122161 }
123162 }
124163
125164 private let objectThrottled = PassthroughSubject < T , Never > ( )
126165}
127166
167+ @MainActor
128168public class ObservableDerivedState < Original: Hashable , Derived: Hashable > : ObservableObject ,
129169 StoreSubscriber , ObservableSubscription
130170{
@@ -151,43 +191,41 @@ public class ObservableDerivedState<Original: Hashable, Derived: Hashable>: Obse
151191
152192 func subscribe( ) {
153193 guard !isSubscribed else { return }
154- // Capture selector directly to avoid retaining self in the transform closure
155194 let selector = self . selector
156- dispatchOnMain ( self ) { instance in
157- guard !instance. isSubscribed else { return }
158- Context . currentContext. store. subscribe (
159- instance, transform: { $0. select ( selector) } )
160- instance. isSubscribed = true
161- }
195+ Context . currentContext. store. subscribe ( self , transform: { $0. select ( selector) } )
196+ isSubscribed = true
162197 }
163198
164199 func unsubscribe( ) {
165200 guard isSubscribed else { return }
166- dispatchOnMain ( self ) { instance in
167- guard instance. isSubscribed else { return }
168- Context . currentContext. store. unsubscribe ( instance)
169- instance. isSubscribed = false
170- }
201+ Context . currentContext. store. unsubscribe ( self )
202+ isSubscribed = false
171203 }
172204
173205 deinit {
174- unsubscribe ( )
206+ // Note: deinit is nonisolated even for @MainActor classes.
207+ // ReSwift's SubscriptionBox holds a weak reference to subscribers,
208+ // so cleanup happens automatically when this object is deallocated.
209+ }
210+
211+ nonisolated public func newState( state original: Original ) {
212+ dispatchToMainActor ( self , state: original) { instance, newState in
213+ instance. applyStateUpdate ( newState)
214+ }
175215 }
176216
177- public func newState( state original: Original ) {
178- dispatchOnMain ( self ) { instance in
179- let old = instance. current
180- instance. objectWillChange. send ( ChangeSubject ( old: old, new: instance. current) )
181-
182- if let animation = instance. animation {
183- withAnimation ( animation) {
184- instance. current = instance. transform ( original)
185- }
186- } else {
187- instance. current = instance. transform ( original)
217+ fileprivate func applyStateUpdate( _ original: Original ) {
218+ let old = current
219+ objectWillChange. send ( ChangeSubject ( old: old, new: current) )
220+
221+ if let animation = animation {
222+ withAnimation ( animation) {
223+ current = transform ( original)
188224 }
189- instance. objectDidChange. send ( ChangeSubject ( old: old, new: instance. current) )
225+ } else {
226+ current = transform ( original)
190227 }
228+ objectDidChange. send ( ChangeSubject ( old: old, new: current) )
191229 }
192230
193231 public let objectWillChange = PassthroughSubject < ChangeSubject < Derived > , Never > ( )
@@ -215,22 +253,27 @@ public class ObservableDerivedThrottledState<Original: Hashable, Derived: Hashab
215253 objectThrottled
216254 . throttle ( for: . milliseconds( throttleInMs) , scheduler: DispatchQueue . main, latest: true )
217255 . sink { [ weak self] in
218- self ? . current = transform ( $0)
256+ guard let self = self else { return }
257+ let old = self . current
258+ self . current = transform ( $0)
259+ self . objectDidChange. send ( ChangeSubject ( old: old, new: self . current) )
219260 }
220261 . store ( in: & cancellables)
221262 }
222263
223- override public func newState( state original: Original ) {
224- dispatchOnMain ( self ) { instance in
225- let old = instance. current
226- if let animation = instance. animation {
227- withAnimation ( animation) {
228- instance. objectThrottled. send ( original)
229- }
230- } else {
231- instance. objectThrottled. send ( original)
264+ nonisolated override public func newState( state original: Original ) {
265+ dispatchToMainActor ( self , state: original) { instance, newState in
266+ instance. applyThrottledStateUpdate ( newState)
267+ }
268+ }
269+
270+ fileprivate func applyThrottledStateUpdate( _ original: Original ) {
271+ if let animation = animation {
272+ withAnimation ( animation) {
273+ objectThrottled. send ( original)
232274 }
233- instance. objectDidChange. send ( ChangeSubject ( old: old, new: instance. current) )
275+ } else {
276+ objectThrottled. send ( original)
234277 }
235278 }
236279
@@ -239,26 +282,30 @@ public class ObservableDerivedThrottledState<Original: Hashable, Derived: Hashab
239282
240283extension Store where State == RowndState {
241284
285+ @MainActor
242286 public func subscribe< T> (
243287 select selector: @escaping ( RowndState ) -> ( T ) , animation: SwiftUI . Animation ? = nil
244288 ) -> ObservableState < T > {
245289 ObservableState ( select: selector, animation: animation)
246290 }
247291
292+ @MainActor
248293 public func subscribe< Original, Derived> (
249294 select selector: @escaping ( RowndState ) -> ( Original ) ,
250295 transform: @escaping ( Original ) -> Derived , animation: SwiftUI . Animation ? = nil
251296 ) -> ObservableDerivedState < Original , Derived > {
252297 ObservableDerivedState ( select: selector, transform: transform, animation: animation)
253298 }
254299
300+ @MainActor
255301 public func subscribeThrottled< T> (
256302 select selector: @escaping ( RowndState ) -> ( T ) , throttleInMs: Int = 350 ,
257303 animation: SwiftUI . Animation ? = nil
258304 ) -> ObservableThrottledState < T > {
259305 ObservableThrottledState ( select: selector, animation: animation, throttleInMs: throttleInMs)
260306 }
261307
308+ @MainActor
262309 public func subscribeThrottled< Original, Derived> (
263310 select selector: @escaping ( RowndState ) -> ( Original ) ,
264311 transform: @escaping ( Original ) -> Derived , throttleInMs: Int = 350 ,
0 commit comments