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 syncNativeClientToJs → refreshJsClientFromServer, 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:
- Emit after a short delay and retry
getDefaultSignedInSession — giving the server time to settle
- Attempt
__internal_reloadInitialResources() before emitting — the same path already used in refreshJsClientFromNativeState's fallback branch
- 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
Summary
After a successful Google OAuth flow via Chrome Custom Tab (CCT) on Android,
isSignedInbriefly dips tofalseapproximately 1 second aftersetActive()resolves, then self-corrects at ~5 seconds. During this windowclerk.client.lastActiveSessionIdis null. Any nav guard watchingisSignedInfires 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) patchesclerkInstance.updateClient. The patch includes a suppressed-emit reconciliation path:This correctly prevents spurious sign-outs when a fallback session exists. But the condition requires
fallbackSessionto be non-null:The race: after the CCT closes, the Clerk native Android module fires a
clerkNativeClientChangedevent concurrently with the JS SDK'ssignIn.reload()+setActive().useNativeClientEventSynccallssyncNativeClientToJs→refreshJsClientFromServer, which does a freshGET /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 aslastActive. The server response hassignedInSessions: []andlastActiveSessionId: null.updateClient(transitionalClient)is called:currentSessionWasRemoved = true(the just-activated session is not insignedInSessions)fallbackSession = getDefaultSignedInSession(transitionalClient) = null(no sessions in the fresh response)(currentSessionWasRemoved || reconciling) && fallbackSessionevaluates tofalse(fallbackSession is null)originalUpdateClient(transitionalClient, options)— normal emit with the empty stateReact sees
isSignedIn = false,lastActiveSessionId = null. Clerk self-corrects at ~5s when a secondclerkNativeClientChangedfires with the settled server state.Evidence
Logcat from Android production build (versionCode 44, diagnostic logging enabled), Google sign-in attempt:
The 115ms
tokenReadyconfirmssetActive()completed and emittedisSignedIn=truebefore 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: trueregardless of whetherfallbackSessionis null. WhenfallbackSessionis null, the suppressed-emit path could either:getDefaultSignedInSession— giving the server time to settle__internal_reloadInitialResources()before emitting — the same path already used inrefreshJsClientFromNativeState's fallback branchcurrentSessionWasRemovedwasn't caused by a transitional server response (e.g., by checking whether the native client event's timestamp is within N seconds of a recentsetActive()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+