Skip to content

Conversation

@mhamann
Copy link
Contributor

@mhamann mhamann commented Dec 27, 2025

Summary by Sourcery

Ensure ReSwift observable state updates and related framework components are safely marshalled to the main actor to prevent crashes from background-thread state notifications.

Bug Fixes:

  • Prevent crashes caused by background-thread access to @published and Combine-backed state by funnelling ReSwift state updates through main-actor–isolated handlers with FIFO ordering.

Enhancements:

  • Refine observable state and derived state types to separate nonisolated newState entry points from main-actor apply* update methods for clearer concurrency boundaries.
  • Simplify store subscription management by subscribing/unsubscribing directly on @MainActor-isolated observable types and relying on ReSwift's weak subscription handling instead of manual cleanup in deinit.
  • Annotate InstantUsers, RowndEventEmitter, and related subscription helpers as @mainactor to clarify and enforce main-thread usage.

Tests:

  • Extend and annotate observable state and subscriber mutation tests with @mainactor to validate the new main-actor–based state handling under concurrent and rapid update scenarios.

@sourcery-ai
Copy link

sourcery-ai bot commented Dec 27, 2025

Reviewer's Guide

Routes ReSwift subscriber callbacks onto the main actor, annotates observable and framework types with @mainactor, simplifies subscription management, and relies on ReSwift’s weak subscription cleanup to avoid stale references and crashes caused by background-thread state updates.

Sequence diagram for main-actor-routed ReSwift state updates

sequenceDiagram
    participant BackgroundThread as Background_thread
    participant Store as Store_RowndState
    participant Observable as ObservableState_T
    participant DispatchHelper as dispatchToMainActor
    participant MainQueue as DispatchQueue_main
    participant MainActor as MainActor_Task
    participant SwiftUI as SwiftUI_View

    BackgroundThread->>Store: dispatch(Action)
    Store->>Store: reduce() RowndState
    Store->>Observable: newState(state)
    note over Observable: nonisolated newState(state : T)

    Observable->>DispatchHelper: dispatchToMainActor(self, state, work)
    DispatchHelper->>MainQueue: async { [weak self] ... }

    MainQueue->>MainQueue: capture weak self
    MainQueue->>MainActor: Task { work(instance, state) }

    MainActor->>Observable: applyStateUpdate(state)
    Observable->>Observable: update current, withAnimation
    Observable-->>SwiftUI: objectDidChange.send(DidChangeSubject)

    SwiftUI->>SwiftUI: re-render view on main thread
Loading

Class diagram for updated observable ReSwift state wrappers

classDiagram
    class DispatchHelpers {
        +dispatchToMainActor(instance, state, work)
    }

    class ObservableSubscription {
        <<protocol>>
    }

    class Store {
        +subscribe(select, animation) ObservableState
        +subscribe(select, transform, animation) ObservableDerivedState
        +subscribeThrottled(select, throttleInMs, animation) ObservableThrottledState
        +subscribeThrottled(select, transform, throttleInMs, animation) ObservableDerivedThrottledState
    }

    class ObservableState~T~ {
        <<@MainActor>>
        -current : T
        -selector
        -animation : Animation?
        -isSubscribed : Bool
        +objectDidChange : PassthroughSubject~DidChangeSubject~T~~, Never~
        +ObservableState(select, animation)
        +subscribe() : Void
        +unsubscribe() : Void
        +nonisolated newState(state : T) : Void
        -applyStateUpdate(state : T) : Void
    }

    class ObservableThrottledState~T~ {
        <<@MainActor>>
        -objectThrottled : PassthroughSubject~T, Never~
        -cancellables : Set~AnyCancellable~
        +ObservableThrottledState(select, animation, throttleInMs)
        +nonisolated newState(state : T) : Void
        -applyThrottledStateUpdate(state : T) : Void
    }

    class ObservableDerivedState~Original, Derived~ {
        <<@MainActor>>
        -current : Derived
        -selector
        -transform(original : Original) Derived
        -animation : Animation?
        -isSubscribed : Bool
        +objectWillChange : PassthroughSubject~ChangeSubject~Derived~~, Never~
        +objectDidChange : PassthroughSubject~ChangeSubject~Derived~~, Never~
        +ObservableDerivedState(select, transform, animation)
        +subscribe() : Void
        +unsubscribe() : Void
        +nonisolated newState(state : Original) : Void
        -applyStateUpdate(original : Original) : Void
    }

    class ObservableDerivedThrottledState~Original, Derived~ {
        <<@MainActor>>
        -objectThrottled : PassthroughSubject~Original, Never~
        -cancellables : Set~AnyCancellable~
        +ObservableDerivedThrottledState(select, transform, animation, throttleInMs)
        +nonisolated newState(state : Original) : Void
        -applyThrottledStateUpdate(original : Original) : Void
    }

    class InstantUsers {
        <<@MainActor>>
        -context : Context
        -cancellables : Set~AnyCancellable~
        +InstantUsers(context : Context)
        +tmpForceInstantUserConversionIfRequested() : Void
    }

    class RowndEventEmitter {
        <<@MainActor>>
        -cancellables : Set~AnyCancellable~
        +emit(event : RowndEvent) : Void
    }

    ObservableState ..|> StoreSubscriber
    ObservableState ..|> ObservableSubscription

    ObservableThrottledState --|> ObservableState
    ObservableDerivedState ..|> StoreSubscriber
    ObservableDerivedState ..|> ObservableSubscription
    ObservableDerivedThrottledState --|> ObservableDerivedState

    Store --> ObservableState : creates
    Store --> ObservableThrottledState : creates
    Store --> ObservableDerivedState : creates
    Store --> ObservableDerivedThrottledState : creates

    DispatchHelpers ..> ObservableState : dispatchToMainActor
    DispatchHelpers ..> ObservableThrottledState : dispatchToMainActor
    DispatchHelpers ..> ObservableDerivedState : dispatchToMainActor
    DispatchHelpers ..> ObservableDerivedThrottledState : dispatchToMainActor

    InstantUsers ..> Store : uses
    RowndEventEmitter ..> Store : uses
Loading

File-Level Changes

Change Details Files
Rework observable ReSwift state types to be @MainActor-isolated while safely handling cross-thread newState callbacks.
  • Replaced the generic dispatchOnMain helper with a dispatchToMainActor function that hops from DispatchQueue.main.async into a @mainactor Task while preserving FIFO ordering of state updates.
  • Marked ObservableState and ObservableDerivedState as @mainactor to ensure all @published and Combine-driven updates occur on the main thread.
  • Changed StoreSubscriber.newState implementations to be nonisolated and delegate to new @MainActor-only apply*StateUpdate helpers via dispatchToMainActor, avoiding direct background-thread access to @published state.
  • Refactored throttled observable variants to override nonisolated newState and use dedicated @mainactor applyThrottledStateUpdate helpers for state and event propagation.
Sources/Rownd/Models/Context/ReSwiftObserver.swift
Simplify subscription management and rely on ReSwift’s weak SubscriptionBox cleanup instead of manual unsubscribe in deinits.
  • Updated ObservableState and ObservableDerivedState subscribe/unsubscribe to call Context.currentContext.store.subscribe/unsubscribe directly under @mainactor instead of dispatching via a helper.
  • Removed explicit unsubscribe calls from deinit, documenting reliance on ReSwift’s weak subscriber cleanup semantics.
  • Annotated Store subscription factory methods with @mainactor to formalize main-thread-only usage for UI-facing subscription helpers.
Sources/Rownd/Models/Context/ReSwiftObserver.swift
Annotate key framework utilities and tests with @mainactor to align with main-thread-only observable state behavior and prevent concurrency issues.
  • Marked InstantUsers as @mainactor to confine its Combine-based behavior to the main actor.
  • Marked RowndEventEmitter as @mainactor so event emission and its shared cancellables collection are main-actor confined.
  • Annotated ObservableStateTests and SubscriberMutationTests with @mainactor so tests execute under the same main-thread-only constraints as production observable state.
Sources/Rownd/framework/InstantUsers.swift
Sources/Rownd/framework/RowndEvent.swift
Tests/RowndTests/ObservableStateTests.swift
Tests/RowndTests/SubscriberMutationTests.swift
Minor formatting and structural cleanup in Rownd bootstrap flow.
  • Adjusted indentation and scoping of InstantUsers.tmpForceInstantUserConversionIfRequested within Rownd.bootstrap logic for clarity without changing behavior.
Sources/Rownd/Rownd.swift

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • Using Task { @MainActor ... } in the nonisolated newState methods can spawn many unstructured tasks and reorder or drop intermediate state updates under high-frequency changes; consider using await MainActor.run { ... } (and making the method async) or some form of coalescing to preserve ordering guarantees.
  • The Task { @MainActor [weak self] ... } pattern is duplicated across the observable state types; consider extracting a small helper (e.g., func onMainActor(_ body: @MainActor @escaping (Self) -> Void)) to reduce boilerplate and keep the weak-self/thread-hopping logic consistent.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Using `Task { @MainActor ... }` in the nonisolated `newState` methods can spawn many unstructured tasks and reorder or drop intermediate state updates under high-frequency changes; consider using `await MainActor.run { ... }` (and making the method async) or some form of coalescing to preserve ordering guarantees.
- The `Task { @MainActor [weak self] ... }` pattern is duplicated across the observable state types; consider extracting a small helper (e.g., `func onMainActor(_ body: @MainActor @escaping (Self) -> Void)`) to reduce boilerplate and keep the weak-self/thread-hopping logic consistent.

## Individual Comments

### Comment 1
<location> `Sources/Rownd/Rownd.swift:103` </location>
<code_context>
-        }
-
-        InstantUsers(context: Context.currentContext)
-            .tmpForceInstantUserConversionIfRequested()
+            InstantUsers(context: Context.currentContext)
+                .tmpForceInstantUserConversionIfRequested()
+        }
</code_context>

<issue_to_address>
**question (bug_risk):** InstantUsers conversion now runs inside the Task instead of immediately after; verify this change in timing is intentional.

Moving `InstantUsers(context: ...)` inside the `Task` changes when this side effect executes (now on the task’s async context instead of immediately after `Task` creation). Please confirm no callers depend on this conversion having started by the time `hydrate()` (or subsequent synchronous code) returns, and that this timing shift is intentional.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@mhamann
Copy link
Contributor Author

mhamann commented Jan 7, 2026

@sourcery-ai review

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The documentation on dispatchToMainActor mentions serializing updates through a Combine subject, but the implementation now uses DispatchQueue.main.async + Task; consider updating the comment to accurately describe the current behavior.
  • In ObservableThrottledState.applyThrottledStateUpdate and ObservableDerivedThrottledState.applyThrottledStateUpdate, the old local is computed but never used; either remove it or wire it into the change notifications if you intended to include the previous value.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The documentation on `dispatchToMainActor` mentions serializing updates through a Combine subject, but the implementation now uses `DispatchQueue.main.async` + `Task`; consider updating the comment to accurately describe the current behavior.
- In `ObservableThrottledState.applyThrottledStateUpdate` and `ObservableDerivedThrottledState.applyThrottledStateUpdate`, the `old` local is computed but never used; either remove it or wire it into the change notifications if you intended to include the previous value.

## Individual Comments

### Comment 1
<location> `Sources/Rownd/Models/Context/ReSwiftObserver.swift:17-26` </location>
<code_context>
+
+/// Dispatches work to the MainActor from a nonisolated context.
+///
+/// This helper serializes state updates through a Combine subject to maintain FIFO ordering,
+/// then processes them on the MainActor. This prevents the ordering issues that can occur
+/// with unstructured Task spawning under high-frequency state changes.
+///
+/// - Parameters:
+///   - instance: The object to operate on (captured weakly)
+///   - state: The state value to process
+///   - work: The MainActor-isolated work to perform
+private func dispatchToMainActor<T: AnyObject, S>(
+    _ instance: T,
+    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 }
</code_context>

<issue_to_address>
**issue:** Doc comment for dispatchToMainActor no longer matches its implementation.

The comment still refers to serializing updates via a Combine subject, but the code now uses `DispatchQueue.main.async` followed by a `Task { @MainActor in ... }` with no Combine involvement. Please update the doc to describe the actual behavior (main queue FIFO + MainActor hop) so future readers can accurately reason about ordering guarantees.
</issue_to_address>

### Comment 2
<location> `Sources/Rownd/Rownd.swift:100` </location>
<code_context>
             }
-        }

-        InstantUsers(context: Context.currentContext)
-            .tmpForceInstantUserConversionIfRequested()
+            InstantUsers(context: Context.currentContext)
+                .tmpForceInstantUserConversionIfRequested()
+        }
</code_context>

<issue_to_address>
**issue (bug_risk):** InstantUsers side-effect is now conditioned on store existence, changing behavior.

`tmpForceInstantUserConversionIfRequested()` used to run regardless of whether `store` existed; now it only runs when `Context.currentContext.store` is non-nil. If this logic should apply even when the store isn’t configured yet, this may unintentionally skip conversions. If the dependency on `store` is intentional, please document it; otherwise, consider moving this call back outside the `if let` or making the dependency explicit.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +17 to +26
/// This helper serializes state updates through a Combine subject to maintain FIFO ordering,
/// then processes them on the MainActor. This prevents the ordering issues that can occur
/// with unstructured Task spawning under high-frequency state changes.
///
/// - Parameters:
/// - instance: The object to operate on (captured weakly)
/// - state: The state value to process
/// - work: The MainActor-isolated work to perform
private func dispatchToMainActor<T: AnyObject, S>(
_ instance: T,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Doc comment for dispatchToMainActor no longer matches its implementation.

The comment still refers to serializing updates via a Combine subject, but the code now uses DispatchQueue.main.async followed by a Task { @MainActor in ... } with no Combine involvement. Please update the doc to describe the actual behavior (main queue FIFO + MainActor hop) so future readers can accurately reason about ordering guarantees.

@mhamann
Copy link
Contributor Author

mhamann commented Jan 7, 2026

/review

@qodo-code-review
Copy link

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Ordering/Doc Mismatch

The new dispatchToMainActor helper documentation mentions serializing via a Combine subject, but the implementation uses DispatchQueue.main.async plus a Task { @MainActor ... }. Confirm the intended ordering/serialization guarantees under high-frequency updates and align the comments with actual behavior to avoid misleading future changes.

// MARK: - Main Actor Dispatch Helper

/// Dispatches work to the MainActor from a nonisolated context.
///
/// This helper serializes state updates through a Combine subject to maintain FIFO ordering,
/// then processes them on the MainActor. This prevents the ordering issues that can occur
/// with unstructured Task spawning under high-frequency state changes.
///
/// - Parameters:
///   - instance: The object to operate on (captured weakly)
///   - state: The state value to process
///   - work: The MainActor-isolated work to perform
private func dispatchToMainActor<T: AnyObject, S>(
    _ instance: T,
    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
            work(instance, state)
        }
    }
Possible Change Signal Bug

In throttled variants, objectDidChange.send(...) is emitted immediately after sending into the throttling subject, but current may not have been updated yet (it updates later in the throttled sink). This can result in observers receiving a change event where new is still the old current, and can also make animation/notification timing inconsistent.

nonisolated override public func newState(state: T) {
    dispatchToMainActor(self, state: state) { instance, newState in
        instance.applyThrottledStateUpdate(newState)
    }
}

fileprivate func applyThrottledStateUpdate(_ state: T) {
    guard current != state else { return }
    let old = current
    if let animation = animation {
        withAnimation(animation) {
            objectThrottled.send(state)
        }
    } else {
        objectThrottled.send(state)
    }
    objectDidChange.send(DidChangeSubject(old: old, new: current))
}
Unsubscribe Semantics

Removing explicit unsubscribe() in deinit relies on ReSwift holding weak references and cleaning up automatically. Validate that ReSwift’s unsubscribe/cleanup behavior is correct in all supported versions and that subscriptions don’t persist longer than expected (e.g., causing extra notifications or resource retention) when objects become unreachable.

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.
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants