Skip to content

fix: restore backward compatibility in state subscription API#123

Merged
mhamann merged 3 commits into
mainfrom
fix/restore-state-subscription-compatibility
Feb 27, 2026
Merged

fix: restore backward compatibility in state subscription API#123
mhamann merged 3 commits into
mainfrom
fix/restore-state-subscription-compatibility

Conversation

@mhamann
Copy link
Copy Markdown
Contributor

@mhamann mhamann commented Feb 26, 2026

User description

Problem

Version 3.14.8 accidentally introduced breaking changes to the state subscription API by adding @MainActor requirements to Store extension methods. This caused existing client code using patterns like Rownd.getInstance().state().subscribe(...) to fail with compiler errors when called from non-MainActor contexts.

The most visible symptom was apps getting stuck on splash screens because state subscription wasn't working.

Solution

This PR restores backward compatibility by:

  • Removing @MainActor requirement from Store extension methods
  • Preserving all thread safety improvements from v3.14.8
  • Keeping ObservableState classes @mainactor isolated
  • Maintaining all crash fixes for stale pointer access

Impact

  • Non-breaking: Only adds capability back, doesn't remove anything
  • Fixes splash screen hanging with existing subscription patterns
  • Restores combinedRowndState.$current usage in client code
  • Preserves all concurrency safety from the original bug fix

Testing

Existing client code patterns like this will work again:

let combinedRowndState = Rownd.getInstance().state().subscribe(select: { $0 })
combinedRowndState.$current
    .receive(on: DispatchQueue.main)
    .removeDuplicates(by: { old, new in
        old.isInitialized == new.isInitialized &&
        old.auth.isAuthenticated == new.auth.isAuthenticated
    })

Ready for v3.14.9 release.


PR Type

Bug fix


Description

  • Removes @mainactor requirement from Store extension methods

  • Restores backward compatibility with existing subscription patterns

  • Preserves thread safety of ObservableState classes

  • Fixes splash screen hanging with state subscription API


Diagram Walkthrough

flowchart LR
  A["Store Extension Methods"] -->|Remove @MainActor| B["Compatible with Non-MainActor Contexts"]
  C["ObservableState Classes"] -->|Keep @MainActor| D["Thread Safety Preserved"]
  B -->|Enables| E["Client Code Works Again"]
  D -->|Maintains| E
Loading

File Walkthrough

Relevant files
Bug fix
ReSwiftObserver.swift
Remove @MainActor from Store extension methods                     

Sources/Rownd/Models/Context/ReSwiftObserver.swift

  • Removed @mainactor annotation from four Store extension methods
  • Added backward compatibility comment explaining the change
  • Methods affected: subscribe, subscribeThrottled (both overloads)
  • ObservableState classes remain @mainactor isolated for thread safety
+1/-4     

Summary by Sourcery

Restore backward-compatible state subscription APIs for Rownd stores by relaxing MainActor constraints while preserving observable state safety.

Bug Fixes:

  • Remove MainActor annotations from Store state subscription helpers to fix compilation and runtime issues in non-MainActor contexts.

Enhancements:

  • Document the restored backward compatibility in the Store subscription extension.

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.
@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Feb 26, 2026

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
🟢
No security concerns identified No security vulnerabilities detected by AI analysis. Human verification advised for critical code.
Ticket Compliance
🎫 No ticket provided
  • Create ticket/issue
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
🟢
Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

  • Update
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Feb 26, 2026

PR Code Suggestions ✨

Latest suggestions up to 913e087

CategorySuggestion                                                                                                                                    Impact
Incremental [*]
Emit correct old/new values

Fix a bug in applyStateUpdate by computing the new state value before calling
objectWillChange.send to ensure observers receive the correct old and new
values.

Sources/Rownd/Models/Context/ReSwiftObserver.swift [210-212]

 @MainActor fileprivate func applyStateUpdate(_ original: Original) {
         let old = current
-        objectWillChange.send(ChangeSubject(old: old, new: current))
+        let new = transform(original)
+        objectWillChange.send(ChangeSubject(old: old, new: new))
+        current = new
  • Apply / Chat
Suggestion importance[1-10]: 9

__

Why: The suggestion correctly identifies a bug where objectWillChange is called with the same old and new values, which would break observers relying on this notification. This is a critical logic error.

High
Possible issue
Fix strict concurrency sendability

To prevent potential data races under strict concurrency, mark the work closure
as @Sendable and wrap the generic state in an @unchecked Sendable struct before
passing it across thread boundaries.

Sources/Rownd/Models/Context/ReSwiftObserver.swift [28-37]

+private struct UncheckedSendable<Value>: @unchecked Sendable {
+    let value: Value
+    init(_ value: Value) { self.value = value }
+}
+
 state: S,
-    work: @escaping @MainActor (T, S) -> Void
+    work: @escaping @MainActor @Sendable (T, S) -> Void
 ) {
-    DispatchQueue.main.async { [weak instance] in
+    let boxedState = UncheckedSendable(state)
+    DispatchQueue.main.async { [weak instance, boxedState] in
         guard let instance = instance else { return }
         Task { @MainActor in
-            work(instance, state)
+            work(instance, boxedState.value)
         }
     }
 }

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies a subtle but important data race issue with non-Sendable types under Swift's strict concurrency model, which is highly relevant to the PR's goal of improving thread safety.

Medium
General
Enforce main-actor update calls

Add MainActor.preconditionIsolated() to the beginning of applyStateUpdate and
other @MainActor methods to enforce they are called from the main actor,
preventing potential thread-safety issues in the future.

Sources/Rownd/Models/Context/ReSwiftObserver.swift [100-109]

 @MainActor fileprivate func applyStateUpdate(_ state: T) {
+    MainActor.preconditionIsolated()
+
     guard current != state else { return }
     let old = current
     if let animation = animation {
         withAnimation(animation) {
             current = state
         }
     } else {
         current = state
     }
 }
  • Apply / Chat
Suggestion importance[1-10]: 6

__

Why: The suggestion proposes a defensive check that improves robustness by ensuring the method is always called on the main actor, which aligns with the PR's goal of ensuring thread safety.

Low
  • More

Previous suggestions

Suggestions up to commit bb1fcdb
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix crash by using MainActor.assumeIsolated

To prevent a runtime crash when calling subscribe methods from a background
thread, wrap the ObservableState initializers within a MainActor.assumeIsolated
block. This ensures the actor-isolated initializers are safely called from the
now non-isolated synchronous functions.

Sources/Rownd/Models/Context/ReSwiftObserver.swift [285-313]

 // BACKWARD COMPATIBLE: Removed @MainActor requirement to restore API compatibility
 public func subscribe<T>(
     select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil
 ) -> ObservableState<T> {
-    ObservableState(select: selector, animation: animation)
+    MainActor.assumeIsolated {
+        ObservableState(select: selector, animation: animation)
+    }
 }
 
 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.assumeIsolated {
+        ObservableDerivedState(select: selector, transform: transform, animation: animation)
+    }
 }
 
 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.assumeIsolated {
+        ObservableThrottledState(select: selector, animation: animation, throttleInMs: throttleInMs)
+    }
 }
 
 public func subscribeThrottled<Original, Derived>(
     select selector: @escaping (RowndState) -> (Original),
     transform: @escaping (Original) -> Derived, throttleInMs: Int = 350,
     animation: SwiftUI.Animation? = nil
 ) -> ObservableThrottledDerivedState<Original, Derived> {
-    ObservableThrottledDerivedState(select: selector, transform: transform, animation: animation, throttleInMs: throttleInMs)
+    MainActor.assumeIsolated {
+        ObservableThrottledDerivedState(select: selector, transform: transform, animation: animation, throttleInMs: throttleInMs)
+    }
 }
Suggestion importance[1-10]: 10

__

Why: The suggestion correctly identifies a critical concurrency bug introduced by removing @MainActor where a background thread call would crash the app. It proposes a robust and modern solution using MainActor.assumeIsolated that fixes the crash while preserving the PR's intent.

High

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Feb 26, 2026

Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

Restores backward-compatible state subscription APIs by removing @mainactor from Store extension subscribe/subscribeThrottled helpers while retaining MainActor isolation inside ObservableState types for thread safety and crash fixes.

Sequence diagram for state subscription from non_MainActor context

sequenceDiagram
    actor App
    participant BackgroundContext
    participant RowndSDK
    participant Store
    participant ObservableState as ObservableState_MainActor

    App->>BackgroundContext: launchAndInitialize()
    BackgroundContext->>RowndSDK: Rownd.getInstance()
    RowndSDK->>Store: state()
    Note over BackgroundContext,Store: Non_MainActor context

    BackgroundContext->>Store: subscribe(select, animation)
    Store->>ObservableState: init(select, animation)
    activate ObservableState
    ObservableState-->>Store: ObservableState<T>
    deactivate ObservableState

    Store-->>BackgroundContext: ObservableState<T>
    BackgroundContext->>ObservableState: access $current (Combine pipeline)
    ObservableState->>ObservableState: updateOnMainActor()
    ObservableState-->>BackgroundContext: emit state updates
    BackgroundContext->>App: updateUIOnMainThread()
Loading

Class diagram for Store extensions and ObservableState types

classDiagram
    direction LR

    class RowndState

    class Store {
        RowndState state
        +subscribe<T>(selector, animation) ObservableState_T
        +subscribe<Original,Derived>(selector, transform, animation) ObservableDerivedState_Original_Derived
        +subscribeThrottled<T>(selector, throttleInMs, animation) ObservableThrottledState_T
        +subscribeThrottled<Original,Derived>(selector, transform, throttleInMs, animation) ObservableDerivedThrottledState_Original_Derived
    }

    class ObservableState_T {
        -T current
        +init(select, animation)
        +publisher() T
    }

    class ObservableDerivedState_Original_Derived {
        -Derived current
        +init(select, transform, animation)
        +publisher() Derived
    }

    class ObservableThrottledState_T {
        -T current
        -Int throttleInMs
        +init(select, animation, throttleInMs)
        +publisher() T
    }

    class ObservableDerivedThrottledState_Original_Derived {
        -Derived current
        -Int throttleInMs
        +init(select, transform, animation, throttleInMs)
        +publisher() Derived
    }

    Store --> RowndState : has
    Store --> ObservableState_T : creates
    Store --> ObservableDerivedState_Original_Derived : creates
    Store --> ObservableThrottledState_T : creates
    Store --> ObservableDerivedThrottledState_Original_Derived : creates
Loading

File-Level Changes

Change Details Files
Restore backward-compatible Store subscription APIs usable from non-MainActor contexts while preserving ObservableState thread-safety model.
  • Removed @mainactor annotations from Store extension subscribe and subscribeThrottled overloads constrained to RowndState.
  • Added documentation comment noting that @mainactor was removed to maintain backward compatibility for existing callers.
  • Kept ObservableState, ObservableDerivedState, and throttled variants as MainActor-isolated implementations, preserving concurrency safety and prior crash fixes.
Sources/Rownd/Models/Context/ReSwiftObserver.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
Copy Markdown

@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 reviewed your changes and they look great!


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 and others added 2 commits February 25, 2026 20:23
…tibility

- 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.
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 <noreply@anthropic.com>
@mhamann mhamann merged commit 0a3c97f into main Feb 27, 2026
3 checks passed
@mhamann mhamann deleted the fix/restore-state-subscription-compatibility branch February 27, 2026 18:19
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