Skip to content

fix(ios): reliable auth-event delivery — replay state on listener attach, dispose sinks on resubscribe (Android parity) - #97

Closed
airowe wants to merge 1 commit into
frontegg:masterfrom
airowe:fix/ios-event-delivery-parity
Closed

fix(ios): reliable auth-event delivery — replay state on listener attach, dispose sinks on resubscribe (Android parity)#97
airowe wants to merge 1 commit into
frontegg:masterfrom
airowe:fix/ios-event-delivery-parity

Conversation

@airowe

@airowe airowe commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Problem

Two related event-delivery gaps in the iOS bridge (ios/FronteggRN.swift), both of which the Android module already handles correctly:

1. State replay on listener attach is conditional. startObserving() only replays the auth state if a change was missed while unobserved (pendingObservingState). The JS-side state copy starts from a default and is only ever corrected by events — so a (re)subscribe that races a native state change leaves JS permanently stale. We hit this on-device: a logout that coincided with app-level teardown never reached JS, useAuth() stayed isAuthenticated: true forever, and the next login's false→true transition detection broke. The failure is invisible from the app: nothing errors, JS just never hears about the state again.

2. subscribe() never disposes previous sinks. Each call adds another set of Combine subscriptions to cancellables, so repeated subscribe() calls multiply event sends. The Android module disposes its disposables before resubscribing and emits current state on subscribe; iOS does neither.

Fix

  • startObserving(): replay the current auth state unconditionally when a listener attaches.
  • subscribe(): cancellables.removeAll() before resubscribing, and push the current state at the end — making subscribe() an on-demand state resync, matching Android.

No public API changes; behavior-only.

Reproduction / validation

Reproduced on a real device (iPhone, iOS 18/26): trigger logout during app teardown (our repro used an app-level cleanup path that raced the auth-state emission) → JS never receives the unauthenticated event. With this patch the state replay on the next listener attach corrects JS immediately. We have been running these exact changes in production QA via patch-package since June across a 50-target white-label workspace.

…ach, dispose sinks on resubscribe

Two related event-delivery gaps in the iOS bridge, both already handled
correctly by the Android module:

1. startObserving() only replayed the auth state when a change had been
   missed while unobserved. The JS-side state copy starts from a default
   and is only ever corrected by events, so a (re)subscribe that races a
   native state change leaves JS permanently stale — observed on-device
   as logout events lost during app-level teardown, leaving useAuth()
   authenticated forever. Replay unconditionally on attach.

2. subscribe() never disposed previous Combine sinks, so repeated calls
   multiplied event sends. Dispose before resubscribing and push the
   current state at the end — matching the Android module, which
   disposes before resubscribing and emits current state on subscribe.
@dianaKhortiuk-frontegg

Copy link
Copy Markdown
Collaborator

Closing — the overlapping part of this PR has been handled on master, and the rest is being tracked as a follow-up.

Handled: the subscribe() sink-disposal fix (FR-25940) landed in #100 (24ac958), which clears cancellables before re-subscribing. Keeping this branch open would now conflict on that exact region.

Not yet on master (follow-up): the unconditional state replay in startObserving() (and pushing current state at the end of subscribe()). #100 only fixed the sink accumulation — it did not add the unconditional replay. Flagging explicitly so this isn't lost; it'll be picked up as a separate, rebased change rather than carrying this branch forward.

Thanks @airowe — the analysis here was spot-on.

dianaKhortiuk-frontegg added a commit that referenced this pull request Jul 24, 2026
Carried from #97 (closed). startObserving() replayed the current auth
state to JS only when a change was missed while unobserved
(pendingObservingState). Because the JS-side state copy starts from a
default and is only corrected by events, a (re)subscribe that races a
native state change left JS permanently stale — e.g. a logout during
app-level teardown left useAuth() stuck authenticated. Replay
unconditionally so a listener attach always resyncs JS.

The companion subscribe() sink-disposal fix from #97 already landed via
#100 (FR-25940); this carries only the remaining startObserving() piece.

Co-Authored-By: Adam Rowe <52685+airowe@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dianaKhortiuk-frontegg added a commit that referenced this pull request Jul 24, 2026
…1.3.12), multi-target SPM (#104)

* fix: make logout() awaitable on both platforms — resolve when the session actually ends

The bridge logout was fire-and-forget on both platforms: iOS called
auth.logout() without the completion overload; Android ignored the SDK's
callback parameter. JS had no way to know when the session was actually
gone and relied solely on the auth-state event — which can be lost when
logout coincides with app-level teardown, leaving the JS state
authenticated forever and breaking the next login's state-transition
detection (observed on-device).

Both native SDKs already expose completion callbacks (iOS
FronteggAuth.logout(_ completion:), Android logout(callback:)); the
bridge just didn't use them. logout() now returns a Promise that
resolves when the native SDK reports completion, and iOS pushes the
final auth state to JS before resolving. The JS export is typed
Promise<void>. Existing callers that ignore the return value are
unaffected.

* fix(android): guarantee logout() promise settles via timeout fallback

logout(promise) resolved only from the SDK completion callback, so
`await logout()` would hang forever if that callback never fired. Add a
10s timeout fallback (parity with the iOS bridge) and an atomic
compare-and-set guard so the promise resolves exactly once, even though
the SDK callback and the timeout may run on different threads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ios): guarantee logout() promise settles via timeout fallback

Harden the awaitable logout() so `await logout()` can never hang if the
SDK completion never fires: add a 10s main-queue timeout fallback and a
`settled` guard so the promise resolves exactly once. Companion to the
Android change; both platforms now share the same 10s settle behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ios): unconditionally replay auth state on listener attach

Carried from #97 (closed). startObserving() replayed the current auth
state to JS only when a change was missed while unobserved
(pendingObservingState). Because the JS-side state copy starts from a
default and is only corrected by events, a (re)subscribe that races a
native state change left JS permanently stale — e.g. a logout during
app-level teardown left useAuth() stuck authenticated. Replay
unconditionally so a listener attach always resyncs JS.

The companion subscribe() sink-disposal fix from #97 already landed via
#100 (FR-25940); this carries only the remaining startObserving() piece.

Co-Authored-By: Adam Rowe <52685+airowe@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(deps): bump native SDKs to Android 1.3.36 / iOS 1.3.12

Picks up the July 2026 mobile-SDK audit fixes shipped in the native
releases: iOS 1.3.12 and Android 1.3.36.

* feat(ios): declare FronteggSwift via React Native's spm_dependency helper (multi-target workspaces)

ios/frontegg_spm.rb text-patches the host's Pods.xcodeproj with object
IDs sized for a single app target. Workspaces with many app targets
(white-label products build dozens from one workspace) can't use it —
the script writes references for a target layout that doesn't match.

React Native >= 0.75 ships an official mechanism for exactly this:
spm_dependency() in a library podspec, applied by
react_native_post_install to every target that consumes the pod, with
no pbxproj text manipulation. Declare FronteggSwift there, guarded by
defined?() so autolinking's out-of-process [!] A specification path is required.

Usage:

    $ pod ipc spec PATH

      Converts a podspec to JSON and prints it to STDOUT.

Options:

    --allow-root   Allows CocoaPods to run as root
    --silent       Show nothing
    --verbose      Show more debugging information
    --no-ansi      Show output without ANSI codes
    --help         Show help banner of specified command evaluation
(which doesn't load react_native_pods.rb) still parses the spec;
frontegg_spm.rb remains the documented fallback for older RN.

Validated on a 50-target workspace (RN 0.81.5, static linkage):
pod install injects one XCRemoteSwiftPackageReference; Debug and
Release builds of two app targets with different team/bundle IDs
succeed with no per-target configuration.

* chore(ios): pin FronteggSwift SPM to 1.3.12 (podspec + Package.swift)

Align the spm_dependency pin and the Package.swift reference manifest to
1.3.12, matching the native-SDK bump in #92 (which covers frontegg_spm.rb
and android/build.gradle). Keeps all iOS SPM integration paths on one
version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(ios): bump FronteggSwift SPM pin to 1.3.13

Update all iOS SPM integration paths (podspec spm_dependency, Package.swift
reference manifest, and the frontegg_spm.rb fallback) from 1.3.12 to the
newly released FronteggSwift 1.3.13. Android SDK pin unchanged (1.3.36).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Adam Rowe <adaminsley@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Adam Rowe <52685+airowe@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants