From bb1fcdbcc6666b6802a83ffdb883df54c1fffe77 Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Wed, 25 Feb 2026 19:57:08 -0500 Subject: [PATCH 1/3] fix: restore backward compatibility in state subscription API Remove @MainActor requirement from Store extension methods that was accidentally introduced in v3.14.8. This restores compatibility with existing client code while preserving all thread safety improvements. - Store.subscribe() methods can now be called from any context - ObservableState classes remain @MainActor for thread safety - All crash fixes from v3.14.8 are preserved - Fixes splash screen hanging issue with existing subscription patterns Fixes issue where combinedRowndState. pattern was broken. --- Sources/Rownd/Models/Context/ReSwiftObserver.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Sources/Rownd/Models/Context/ReSwiftObserver.swift b/Sources/Rownd/Models/Context/ReSwiftObserver.swift index f34fa9a..69d92e0 100644 --- a/Sources/Rownd/Models/Context/ReSwiftObserver.swift +++ b/Sources/Rownd/Models/Context/ReSwiftObserver.swift @@ -282,14 +282,13 @@ 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 +296,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 +303,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, From 8a2d3bee0ddf8a151f632bb10e01be74ef1acb60 Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Wed, 25 Feb 2026 20:23:57 -0500 Subject: [PATCH 2/3] fix: make ObservableState initializers nonisolated for backward compatibility - Mark initializers as nonisolated to allow calling from non-MainActor contexts - Keep classes @MainActor for thread safety of @Published properties - Fix compilation errors introduced by removing @MainActor from Store extensions This preserves all thread safety improvements while restoring API compatibility. --- Sources/Rownd/Models/Context/ReSwiftObserver.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/Rownd/Models/Context/ReSwiftObserver.swift b/Sources/Rownd/Models/Context/ReSwiftObserver.swift index 69d92e0..1cb1ad0 100644 --- a/Sources/Rownd/Models/Context/ReSwiftObserver.swift +++ b/Sources/Rownd/Models/Context/ReSwiftObserver.swift @@ -64,7 +64,7 @@ public class ObservableState: ObservableObject, StoreSubscriber, Ob // MARK: Lifecycle - public init(select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil) + nonisolated public init(select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil) { self.current = selector(Context.currentContext.store.state) self.selector = selector @@ -72,7 +72,7 @@ public class ObservableState: ObservableObject, StoreSubscriber, Ob self.subscribe() } - public func subscribe() { + nonisolated public func subscribe() { guard !isSubscribed else { return } let selector = self.selector Context.currentContext.store.subscribe(self, transform: { $0.select(selector) }) @@ -127,7 +127,7 @@ public class ObservableThrottledState: ObservableState { // MARK: Lifecycle - public init( + nonisolated public init( select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil, throttleInMs: Int ) { @@ -178,7 +178,7 @@ public class ObservableDerivedState: Obse // MARK: Lifecycle - public init( + nonisolated public init( select selector: @escaping (RowndState) -> Original, transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil ) { @@ -189,7 +189,7 @@ public class ObservableDerivedState: Obse self.subscribe() } - func subscribe() { + nonisolated func subscribe() { guard !isSubscribed else { return } let selector = self.selector Context.currentContext.store.subscribe(self, transform: { $0.select(selector) }) @@ -243,7 +243,7 @@ public class ObservableDerivedThrottledState Original, transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil, throttleInMs: Int From 913e087006e8faa1f96d6bb7d9a11807dc8ec430 Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Fri, 27 Feb 2026 13:04:25 -0500 Subject: [PATCH 3/3] fix: remove @MainActor from classes to restore backward compat The previous crash fix (7fe6b4a) added @MainActor to ObservableState and ObservableDerivedState, which broke customers who call store.subscribe(select:) from non-MainActor contexts. The follow-up attempts to mark init/subscribe as nonisolated caused compiler errors since nonisolated methods can't access actor-isolated properties. Instead, remove @MainActor from the class declarations entirely and keep thread safety through the existing dispatchToMainActor helper, which ensures all @Published property mutations happen on the main thread. The applyStateUpdate methods are explicitly marked @MainActor so the compiler still enforces safety at the mutation callsite. Co-Authored-By: Claude Opus 4.6 --- .../Models/Context/ReSwiftObserver.swift | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/Sources/Rownd/Models/Context/ReSwiftObserver.swift b/Sources/Rownd/Models/Context/ReSwiftObserver.swift index 1cb1ad0..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 { @@ -64,7 +61,7 @@ public class ObservableState: ObservableObject, StoreSubscriber, Ob // MARK: Lifecycle - nonisolated public init(select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil) + public init(select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil) { self.current = selector(Context.currentContext.store.state) self.selector = selector @@ -72,7 +69,7 @@ public class ObservableState: ObservableObject, StoreSubscriber, Ob self.subscribe() } - nonisolated public func subscribe() { + public func subscribe() { guard !isSubscribed else { return } let selector = self.selector Context.currentContext.store.subscribe(self, transform: { $0.select(selector) }) @@ -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 { @@ -127,7 +122,7 @@ public class ObservableThrottledState: ObservableState { // MARK: Lifecycle - nonisolated public init( + public init( select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil, throttleInMs: Int ) { @@ -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 { @@ -178,7 +172,7 @@ public class ObservableDerivedState: Obse // MARK: Lifecycle - nonisolated public init( + public init( select selector: @escaping (RowndState) -> Original, transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil ) { @@ -189,7 +183,7 @@ public class ObservableDerivedState: Obse self.subscribe() } - nonisolated func subscribe() { + func subscribe() { guard !isSubscribed else { return } let selector = self.selector Context.currentContext.store.subscribe(self, transform: { $0.select(selector) }) @@ -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)) @@ -243,7 +236,7 @@ public class ObservableDerivedThrottledState Original, transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil, throttleInMs: Int @@ -267,7 +260,7 @@ public class ObservableDerivedThrottledState( select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil ) -> ObservableState {