Skip to content

[expo] NativeClientSync overwrites freshly-activated OAuth session with intermediate server state, causing transient isSignedIn=false on Android CCT #9589

Description

@michaelbrianlee-arch

Summary

After a successful Google OAuth flow via Chrome Custom Tab (CCT) on Android, isSignedIn briefly dips to false approximately 1 second after setActive() resolves, then self-corrects at ~5 seconds. During this window clerk.client.lastActiveSessionId is null. Any nav guard watching isSignedIn fires during the dip.

SDK version: @clerk/expo (latest, Clerk Core 3)
Platform: Android
Reproduction: Google OAuth via useOAuth({ strategy: 'oauth_google' })openAuthSessionAsync (CCT) → setActive()


Root cause (traced to SDK source)

NativeClientSync (@clerk/expo/dist/provider/nativeClientSync.js) patches clerkInstance.updateClient. The patch includes a suppressed-emit reconciliation path:

if ((currentSessionWasRemoved || alreadyReconcilingRemovedActiveSession) && fallbackSession) {
  isReconcilingRemovedActiveSession = true;
  originalUpdateClient(newClient, { __internal_dangerouslySkipEmit: true }); // ← holds React
  appendToQueue(async () => {
    runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => {
      try {
        await clerkInstance.setActive?.({ session: fallbackSession });
      } catch (error) {
        originalUpdateClient(newClient, options);
      } finally {
        isReconcilingRemovedActiveSession = false;
      }
    });
  });
  return;
}
originalUpdateClient(newClient, options); // ← normal emit path

This correctly prevents spurious sign-outs when a fallback session exists. But the condition requires fallbackSession to be non-null:

function getDefaultSignedInSession(client) {
  if (!client) return null;
  if (client.lastActiveSessionId) {
    const lastActiveSession = client.signedInSessions.find(
      (session) => session.id === client.lastActiveSessionId
    );
    if (lastActiveSession) return lastActiveSession;
  }
  return client.signedInSessions[0] ?? null;
}

The race: after the CCT closes, the Clerk native Android module fires a clerkNativeClientChanged event concurrently with the JS SDK's signIn.reload() + setActive(). useNativeClientEventSync calls syncNativeClientToJsrefreshJsClientFromServer, which does a fresh GET /v1/client. At the moment of this GET, the server is in a transitional state: the OAuth sign-in resource has been finalized but the session is not yet settled as lastActive. The server response has signedInSessions: [] and lastActiveSessionId: null.

updateClient(transitionalClient) is called:

  • currentSessionWasRemoved = true (the just-activated session is not in signedInSessions)
  • fallbackSession = getDefaultSignedInSession(transitionalClient) = null (no sessions in the fresh response)
  • Condition (currentSessionWasRemoved || reconciling) && fallbackSession evaluates to false (fallbackSession is null)
  • Falls through to originalUpdateClient(transitionalClient, options)normal emit with the empty state

React sees isSignedIn = false, lastActiveSessionId = null. Clerk self-corrects at ~5s when a second clerkNativeClientChanged fires with the settled server state.


Evidence

Logcat from Android production build (versionCode 44, diagnostic logging enabled), Google sign-in attempt:

07:57:29.099  ReactNativeJS: [homeWater] tokenReady after 115ms from mount
  ↑ setActive() confirmed successful — home screen mounted, token available

07:57:30.169  ReactNativeJS: [homeWater] userId changed → resetting home-water state
07:57:30.169  ReactNativeJS: [navguard] recovery check: sessionId= null isSignedIn= false
  ↑ 1070ms later: isSignedIn=false, lastActiveSessionId=null — the dip
  ↑ Both fire in the same React batch (same ms), confirming atomic client state reset

The 115ms tokenReady confirms setActive() completed and emitted isSignedIn=true before the dip. The dip happens 1070ms later — consistent with the native client event firing after the CCT close triggers native SDK processing.


Expected behavior

When currentSessionWasRemoved = true, the SDK should use __internal_dangerouslySkipEmit: true regardless of whether fallbackSession is null. When fallbackSession is null, the suppressed-emit path could either:

  1. Emit after a short delay and retry getDefaultSignedInSession — giving the server time to settle
  2. Attempt __internal_reloadInitialResources() before emitting — the same path already used in refreshJsClientFromNativeState's fallback branch
  3. Accept that the session was genuinely removed and emit normally — but only after verifying currentSessionWasRemoved wasn't caused by a transitional server response (e.g., by checking whether the native client event's timestamp is within N seconds of a recent setActive() call)

The current behavior — emitting the null state immediately when currentSessionWasRemoved=true && fallbackSession=null — silently degrades every Android Google OAuth sign-in into a nav guard race.


Workaround (client side)

We're suppressing the nav guard redirect for 8s after setActive() resolves (Android only). This avoids the ejection during the self-correction window, but doesn't fix the root SDK race.

Versions

  • @clerk/expo: latest (Clerk Core 3)
  • expo: 53+
  • React Native: 0.79+
  • Android: API 36 (Android 16), also reproduced on earlier API levels

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions