Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 11 additions & 22 deletions Sources/Rownd/Models/Context/ReSwiftObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ private func dispatchToMainActor<T: AnyObject, S>(
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
Expand All @@ -40,19 +39,17 @@ private func dispatchToMainActor<T: AnyObject, S>(
// 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<T: Hashable>: ObservableObject, StoreSubscriber, ObservableSubscription
{

Expand Down Expand Up @@ -86,23 +83,21 @@ public class ObservableState<T: Hashable>: 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 {
Expand Down Expand Up @@ -150,7 +145,7 @@ public class ObservableThrottledState<T: Hashable>: ObservableState<T> {
}
}

fileprivate func applyThrottledStateUpdate(_ state: T) {
@MainActor fileprivate func applyThrottledStateUpdate(_ state: T) {
guard current != state else { return }
if let animation = animation {
withAnimation(animation) {
Expand All @@ -164,7 +159,6 @@ public class ObservableThrottledState<T: Hashable>: ObservableState<T> {
private let objectThrottled = PassthroughSubject<T, Never>()
}

@MainActor
public class ObservableDerivedState<Original: Hashable, Derived: Hashable>: ObservableObject,
StoreSubscriber, ObservableSubscription
{
Expand Down Expand Up @@ -203,7 +197,6 @@ public class ObservableDerivedState<Original: Hashable, Derived: Hashable>: 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.
}
Expand All @@ -214,7 +207,7 @@ public class ObservableDerivedState<Original: Hashable, Derived: Hashable>: Obse
}
}

fileprivate func applyStateUpdate(_ original: Original) {
@MainActor fileprivate func applyStateUpdate(_ original: Original) {
let old = current
objectWillChange.send(ChangeSubject(old: old, new: current))

Expand Down Expand Up @@ -267,7 +260,7 @@ public class ObservableDerivedThrottledState<Original: Hashable, Derived: Hashab
}
}

fileprivate func applyThrottledStateUpdate(_ original: Original) {
@MainActor fileprivate func applyThrottledStateUpdate(_ original: Original) {
if let animation = animation {
withAnimation(animation) {
objectThrottled.send(original)
Expand All @@ -282,30 +275,26 @@ public class ObservableDerivedThrottledState<Original: Hashable, Derived: Hashab

extension Store where State == RowndState {

@MainActor
public func subscribe<T>(
select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil
) -> ObservableState<T> {
ObservableState(select: selector, animation: animation)
}

@MainActor
public func subscribe<Original, Derived>(
select selector: @escaping (RowndState) -> (Original),
transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil
) -> ObservableDerivedState<Original, Derived> {
ObservableDerivedState(select: selector, transform: transform, animation: animation)
}

@MainActor
public func subscribeThrottled<T>(
select selector: @escaping (RowndState) -> (T), throttleInMs: Int = 350,
animation: SwiftUI.Animation? = nil
) -> ObservableThrottledState<T> {
ObservableThrottledState(select: selector, animation: animation, throttleInMs: throttleInMs)
}

@MainActor
public func subscribeThrottled<Original, Derived>(
select selector: @escaping (RowndState) -> (Original),
transform: @escaping (Original) -> Derived, throttleInMs: Int = 350,
Expand Down
Loading