From e16252a011765a7468394063a6d00c03c899da7f Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 30 Jul 2026 12:18:52 +0300 Subject: [PATCH 1/7] fix: harden native session-minter token path Native apps feed the previous session token to the edge Session Minter as the mint seed, so a stale or regressed lastActiveToken is no longer just a stale local token, it becomes the input to the next mint and the staleness chains forward. Three gaps made that reachable on the Expo/native path. clerk-js: Client.fromJSON rebuilds every session object on a client update, and the rebuilt objects replaced the live ones while unconditionally adopting the payload's last_active_token. A piggybacked response carrying an older token could therefore regress the active session's token. fromJSON now carries the freshest of the prior instance's token and the payload token, using the same same-sid/same-org oiat guard that already protects the in-place path, so a genuine session or org switch still adopts the incoming token while a stale piggyback cannot win. Token-clearing on a token-less payload is preserved. expo: a failed initial load substituted dummy resources for both environment and client, wiping auth_config.session_minter for the whole instance lifetime even when a good cached environment existed; it now substitutes only the missing resource. The patched 401 handler ran full native-state recovery plus a client refetch on every 401; a short cooldown collapses a burst to one cycle, and a rotated device token clears the cooldown so fresh native identity always gets a fresh attempt. tokenCache doc corrected to say it stores the client JWT. --- .changeset/lucky-donkeys-brake.md | 5 + .changeset/tender-pugs-shave.md | 9 ++ .../clerk-js/src/core/resources/Client.ts | 8 +- .../clerk-js/src/core/resources/Session.ts | 50 +++++--- .../core/resources/__tests__/Session.test.ts | 118 +++++++++++++++++- .../cache/dummy-data/environment-resource.ts | 1 + packages/expo/src/provider/ClerkProvider.tsx | 2 +- .../ClerkProvider.nativeClientSync.test.tsx | 99 ++++++++++++++- .../expo/src/provider/nativeClientSync.tsx | 13 ++ .../__tests__/createClerkInstance.test.ts | 33 +++++ .../provider/singleton/createClerkInstance.ts | 12 +- 11 files changed, 325 insertions(+), 25 deletions(-) create mode 100644 .changeset/lucky-donkeys-brake.md create mode 100644 .changeset/tender-pugs-shave.md diff --git a/.changeset/lucky-donkeys-brake.md b/.changeset/lucky-donkeys-brake.md new file mode 100644 index 00000000000..b9153adc9e8 --- /dev/null +++ b/.changeset/lucky-donkeys-brake.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Keep the freshest session token when a server response carries an older one. A slow response, or the client payload attached to one, could previously roll `lastActiveToken` back to a stale token, which is the token sent as the previous-token hint on the next token request. diff --git a/.changeset/tender-pugs-shave.md b/.changeset/tender-pugs-shave.md new file mode 100644 index 00000000000..1d1eceb2e3a --- /dev/null +++ b/.changeset/tender-pugs-shave.md @@ -0,0 +1,9 @@ +--- +'@clerk/expo': patch +--- + +Keep a cached environment when the app starts offline and only the client cache is empty. Previously both resources fell back to placeholder data, so instance settings were lost until the app was restarted with a working network. + +Repeated unauthenticated responses now share one native recovery attempt within a few seconds of each other, instead of reading native state and refetching the client for every response. + +Fix the `tokenCache` prop documentation: the cache stores the client JWT, not the session token. diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 7697b936a66..68156cd1907 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -141,7 +141,13 @@ export class Client extends BaseResource implements ClientResource { fromJSON(data: ClientJSON | ClientJSONSnapshot | null): this { if (data) { this.id = data.id; - this.sessions = (data.sessions || []).map(s => new Session(s)); + // Rebuilt session objects replace the live ones, so a stale piggybacked token must not win. + const previousTokens = new Map(this.sessions.map(session => [session.id, session.lastActiveToken])); + this.sessions = (data.sessions || []).map(s => { + const session = new Session(s); + session.__internal_keepFreshestLastActiveToken(previousTokens.get(session.id)); + return session; + }); if (data.sign_up && this.signUp instanceof SignUp && this.signUp.id === data.sign_up.id) { this.signUp.__internal_updateFromJSON(data.sign_up); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 1ae3efb157b..f4f262ed2de 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -58,6 +58,23 @@ import { SessionVerification } from './SessionVerification'; const focusedRefresh = (onRefresh: () => void): { onRefresh?: () => void } => isTabFocused() === false ? {} : { onRefresh }; +// Mirrors the cookie guard: only a same session+org lastActiveToken is a comparable +// freshness baseline, so a session or org switch always adopts the incoming token. +// Without this, an org-switch token minted by a stale edge (lower oiat) would lose +// to the previous org's token and pin lastActiveToken to the old org's claims. +function shouldKeepExistingLastActiveToken(current: TokenResource | null | undefined, incoming: TokenResource) { + if (!current?.jwt) { + return false; + } + if ( + tokenSid(current) !== tokenSid(incoming) || + normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming)) + ) { + return false; + } + return pickFreshestJwt(current, incoming) !== incoming; +} + export class Session extends BaseResource implements SessionResource { pathRoot = '/client/sessions'; @@ -411,7 +428,11 @@ export class Session extends BaseResource implements SessionResource { this.publicUserData = new PublicUserData(data.public_user_data); } - this.lastActiveToken = data.last_active_token ? new Token(data.last_active_token) : null; + // Responses are applied in place on a live session, so a piggybacked token can be staler than the one held. + const incomingLastActiveToken = data.last_active_token ? new Token(data.last_active_token) : null; + if (!incomingLastActiveToken || !shouldKeepExistingLastActiveToken(this.lastActiveToken, incomingLastActiveToken)) { + this.lastActiveToken = incomingLastActiveToken; + } return this; } @@ -528,28 +549,25 @@ export class Session extends BaseResource implements SessionResource { eventBus.emit(events.TokenUpdate, { token }); - if (token.jwt && !this.#shouldKeepExistingLastActiveToken(token)) { + if (token.jwt && !shouldKeepExistingLastActiveToken(this.lastActiveToken, token)) { this.lastActiveToken = token; eventBus.emit(events.SessionTokenResolved, null); } } - // Mirrors the cookie guard: only a same session+org lastActiveToken is a comparable - // freshness baseline, so a session or org switch always adopts the incoming token. - // Without this, an org-switch token minted by a stale edge (lower oiat) would lose - // to the previous org's token and pin lastActiveToken to the old org's claims. - #shouldKeepExistingLastActiveToken(incoming: TokenResource): boolean { - const current = this.lastActiveToken; - if (!current?.jwt) { - return false; + /** + * Carries a token forward from the Session instance this one replaces. A client payload rebuilds + * every session object, so without this a stale piggybacked token becomes the next mint seed. + * + * @internal + */ + public __internal_keepFreshestLastActiveToken(previous: TokenResource | null | undefined): void { + if (!previous || !this.lastActiveToken) { + return; } - if ( - tokenSid(current) !== tokenSid(incoming) || - normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming)) - ) { - return false; + if (shouldKeepExistingLastActiveToken(previous, this.lastActiveToken)) { + this.lastActiveToken = previous; } - return pickFreshestJwt(current, incoming) !== incoming; } #fetchToken( diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index b87e4e4041c..6e017fe94f6 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -15,7 +15,7 @@ import { TokenId } from '@/utils/tokenId'; import { eventBus } from '../../events'; import { createFapiClient } from '../../fapiClient'; import { SessionTokenCache } from '../../tokenCache'; -import { BaseResource, Organization, Session } from '../internal'; +import { BaseResource, Client, Organization, Session } from '../internal'; const baseFapiClientOptions = { frontendApi: 'clerk.example.com', @@ -2269,6 +2269,7 @@ describe('Session', () => { afterEach(() => { dispatchSpy?.mockRestore(); fetchSpy?.mockRestore(); + Client.clearInstance(); BaseResource.clerk = null as any; SessionTokenCache.clear(); }); @@ -2444,5 +2445,120 @@ describe('Session', () => { // wins even though a stale edge minted it with a lower oiat. expect(session.lastActiveToken?.getRawString()).toBe(orgLow); }); + + describe('fromJSON', () => { + const tokenJSON = (jwt: string) => ({ object: 'token' as const, id: 'tok_1', jwt }); + + const touchResponse = (lastActiveToken: ReturnType | null) => ({ + response: { + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: Date.now(), + updated_at: Date.now(), + last_active_token: lastActiveToken, + } as unknown as SessionJSON, + }); + + it('a stale touch response does not regress lastActiveToken', async () => { + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + const session = makeSession({ last_active_token: tokenJSON(high) } as Partial); + + fetchSpy.mockResolvedValueOnce(touchResponse(tokenJSON(low)) as any); + await session.touch(); + + expect(session.lastActiveToken?.getRawString()).toBe(high); + }); + + it('a fresher touch response replaces lastActiveToken', async () => { + const low = createJwtWithOiat(NOW, NOW); + const high = createJwtWithOiat(NOW, NOW + 30); + const session = makeSession({ last_active_token: tokenJSON(low) } as Partial); + + fetchSpy.mockResolvedValueOnce(touchResponse(tokenJSON(high)) as any); + await session.touch(); + + expect(session.lastActiveToken?.getRawString()).toBe(high); + }); + + it('a touch response without a token still clears lastActiveToken', async () => { + const high = createJwtWithOiat(NOW, NOW + 30); + const session = makeSession({ last_active_token: tokenJSON(high) } as Partial); + + fetchSpy.mockResolvedValueOnce(touchResponse(null) as any); + await session.touch(); + + expect(session.lastActiveToken).toBeNull(); + }); + + it('constructor hydration adopts the token in the payload', () => { + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + + expect( + makeSession({ last_active_token: tokenJSON(high) } as Partial).lastActiveToken?.getRawString(), + ).toBe(high); + expect( + makeSession({ last_active_token: tokenJSON(low) } as Partial).lastActiveToken?.getRawString(), + ).toBe(low); + }); + + it('a stale piggybacked client does not regress the active session token', async () => { + // Exercises the real path: fapi response -> _updateClient -> Client.fromJSON rebuild. + fetchSpy.mockRestore(); + + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + + const sessionJSON = (jwt: string) => + ({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: Date.now(), + updated_at: Date.now(), + last_active_token: tokenJSON(jwt), + }) as unknown as SessionJSON; + + const clientJSON = (jwt: string) => + ({ + object: 'client', + id: 'client_1', + last_active_session_id: 'session_1', + sessions: [sessionJSON(jwt)], + }) as any; + + const client = Client.getOrCreateInstance().fromJSON(clientJSON(high)); + const clerk: any = { + __internal_environment: { authConfig: { sessionMinter: true } }, + client, + session: client.sessions[0], + getFapiClient: () => ({ + request: vi.fn().mockResolvedValue({ + status: 200, + payload: { response: sessionJSON(low), client: clientJSON(low) }, + }), + }), + }; + clerk.updateClient = (newClient: any) => { + clerk.client = newClient; + clerk.session = newClient.sessions.find((s: Session) => s.id === newClient.lastActiveSessionId); + }; + BaseResource.clerk = clerk; + + expect(clerk.session.lastActiveToken?.getRawString()).toBe(high); + + await clerk.session.touch(); + + expect(clerk.session.lastActiveToken?.getRawString()).toBe(high); + }); + }); }); }); diff --git a/packages/expo/src/cache/dummy-data/environment-resource.ts b/packages/expo/src/cache/dummy-data/environment-resource.ts index 9ac020f988f..8a31023ec7a 100644 --- a/packages/expo/src/cache/dummy-data/environment-resource.ts +++ b/packages/expo/src/cache/dummy-data/environment-resource.ts @@ -9,6 +9,7 @@ export const DUMMY_CLERK_ENVIRONMENT_RESOURCE = { single_session_mode: true, claimed_at: null, reverification: true, + session_minter: false, }, display_config: { object: 'display_config', diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 127600b48a9..720f424031c 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -32,7 +32,7 @@ export type ClerkProviderProps = Omit { user: { id: 'user_1' }, }; const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; + let reentersUnauthenticated = false; mocks.clerkInstance.client = { signedInSessions: [removedSession], lastActiveSessionId: 'session_1', fetch: vi.fn().mockImplementation(async () => { - await mocks.clerkInstance.handleUnauthenticated(); + if (reentersUnauthenticated) { + await mocks.clerkInstance.handleUnauthenticated(); + } throw new Error('stale session 401'); }), }; @@ -1177,6 +1180,7 @@ describe('ClerkProvider native client sync', () => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); }); + reentersUnauthenticated = true; await act(async () => { await mocks.clerkInstance.handleUnauthenticated(); }); @@ -1231,6 +1235,99 @@ describe('ClerkProvider native client sync', () => { expect(originalHandleUnauthenticated).toHaveBeenCalled(); }); + test('runs native recovery once for a burst of unauthenticated responses', async () => { + const removedSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; + const fetchClient = vi.fn().mockResolvedValue(null); + + mocks.clerkInstance.client = { + signedInSessions: [removedSession], + lastActiveSessionId: 'session_1', + fetch: fetchClient, + }; + mocks.clerkInstance.session = removedSession; + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); + }); + + await act(async () => { + await mocks.clerkInstance.handleUnauthenticated(); + await mocks.clerkInstance.handleUnauthenticated(); + }); + + expect(fetchClient).toHaveBeenCalledTimes(1); + expect(originalHandleUnauthenticated).toHaveBeenCalledTimes(2); + }); + + test('recovers again inside the cooldown window once native pushes a new device token', async () => { + const session = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; + const fetchClient = vi.fn(); + const client = { + id: 'client_1', + signedInSessions: [session], + lastActiveSessionId: 'session_1', + fetch: fetchClient, + }; + fetchClient.mockResolvedValue(client); + + mocks.clerkInstance.client = client; + mocks.clerkInstance.session = session; + mocks.getClientToken.mockResolvedValue('native-client-token'); + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); + }); + + // Drop the client fetches the bootstrap already made; only the 401 handling matters here. + fetchClient.mockClear(); + + await act(async () => { + await mocks.clerkInstance.handleUnauthenticated(); + }); + expect(fetchClient).toHaveBeenCalledTimes(1); + + await act(async () => { + await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'rotated-native-client-token'); + }); + + await act(async () => { + await mocks.clerkInstance.handleUnauthenticated(); + }); + + expect(fetchClient).toHaveBeenCalledTimes(2); + expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); + }); + test('refreshes native from the server after the JS client changes', async () => { mocks.tokenCache.getToken.mockResolvedValue(null); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 5c564d92f32..7a4eea73dec 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -12,6 +12,7 @@ const tokenCacheReadTimeoutMs = 1_000; const nativeDeviceTokenPollIntervalMs = 100; const nativeDeviceTokenAvailabilityTimeoutMs = 3_000; const nativeClientSyncSourceIdPrefix = 'clerk-expo-js-sync'; +const unauthenticatedRecoveryCooldownMs = 5_000; export type SyncableClerkInstance = { addListener?: (listener: () => void, options?: { skipInitialEmit?: boolean }) => () => void; @@ -570,6 +571,7 @@ export function NativeClientSync({ const pendingNativeRefreshRef = useRef(null); const pendingNativeRefreshBeforeReadyRef = useRef(null); const nativeRefreshGenerationRef = useRef(0); + const lastUnauthenticatedRecoveryRef = useRef(undefined); const enabledRef = useRef(enabled); enabledRef.current = enabled; @@ -743,6 +745,9 @@ export function NativeClientSync({ useEffect(() => { const listener: DeviceTokenCacheListener = deviceToken => { + // A rotated device token is new input for recovery, so it reopens the unauthenticated cooldown. + lastUnauthenticatedRecoveryRef.current = undefined; + const options = { deviceToken, didChangeClient: false, @@ -784,6 +789,14 @@ export function NativeClientSync({ isHandlingUnauthenticated = true; try { + // Re-reading native state and refetching the client for every response in a 401 burst only amplifies it. + const now = Date.now(); + const lastRecovery = lastUnauthenticatedRecoveryRef.current; + if (lastRecovery !== undefined && now - lastRecovery < unauthenticatedRecoveryCooldownMs) { + return await originalHandleUnauthenticated(options); + } + lastUnauthenticatedRecoveryRef.current = now; + return await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { try { const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index 67544ad0866..8ece24b5646 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -2,6 +2,7 @@ import type { Clerk } from '@clerk/clerk-js'; import { ClerkRuntimeError } from '@clerk/shared/error'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { DUMMY_CLERK_CLIENT_RESOURCE } from '../../../cache'; import type { TokenCache } from '../../../cache/types'; import { CLERK_CLIENT_JWT_KEY } from '../../../constants'; @@ -44,6 +45,12 @@ const createUnavailableResourceCache = () => ({ set: () => Promise.resolve(), }); +const createEnvironmentOnlyResourceCache = (environment: unknown) => () => ({ + get: (key: string) => + Promise.resolve(key.startsWith('__clerk_cache_environment') ? JSON.stringify(environment) : null), + set: () => Promise.resolve(), +}); + const loadCreateClerkInstance = async () => { const mod = await import('../createClerkInstance'); return mod.createClerkInstance; @@ -483,6 +490,32 @@ describe('createClerkInstance', () => { } }); + test('keeps a cached environment when the client cache is empty', async () => { + mocks.requestInitialResources.mockImplementation(() => new Promise(() => {})); + + const cachedEnvironment = { + object: 'environment', + id: 'env_cached', + auth_config: { object: 'auth_config', id: 'aac_cached', session_minter: true }, + }; + + const createClerkInstance = await loadCreateClerkInstance(); + const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); + const clerk = getClerkInstance({ + publishableKey: 'pk_test_123', + __experimental_resourceCache: createEnvironmentOnlyResourceCache(cachedEnvironment), + }) as unknown as MockClerk; + + const resources = await clerk.__internal_getCachedResources?.(); + + expect(resources?.environment).toEqual(cachedEnvironment); + expect(resources?.client).toMatchObject({ id: DUMMY_CLERK_CLIENT_RESOURCE.id }); + + // The client is still missing, so recovery is still scheduled. + await vi.advanceTimersByTimeAsync(3_000); + expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1); + }); + test('stops recovering after the initial resources load', async () => { mocks.requestInitialResources.mockResolvedValue(undefined); diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index 48ec802af10..c9f15ab51cf 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -260,14 +260,16 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null; }> => { - let environment = await EnvironmentResourceCache.load(); - let client = await ClientResourceCache.load(); + const environment = await EnvironmentResourceCache.load(); + const client = await ClientResourceCache.load(); if (!environment || !client) { - environment = DUMMY_CLERK_ENVIRONMENT_RESOURCE; - client = DUMMY_CLERK_CLIENT_RESOURCE; scheduleResourceRetry(3000); } - return { client, environment }; + // Substitute only what is missing: a dummy environment drops instance settings for the whole app run. + return { + client: client ?? DUMMY_CLERK_CLIENT_RESOURCE, + environment: environment ?? DUMMY_CLERK_ENVIRONMENT_RESOURCE, + }; }; } } From e94cb0335ba1882676b2c6b5c2020e11a404b93d Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 30 Jul 2026 12:35:31 +0300 Subject: [PATCH 2/7] fix(expo): keep the 401 cooldown when a failed recovery rolls back the device token The native 401 cooldown clears whenever the device-token cache changes, so a fresh external identity gets a fresh recovery attempt. But a failed recovery rolls the device token back to its previous value, and that rollback write fired the same listener and cleared the cooldown, so a second 401 inside the window re-ran full recovery, reopening the storm on exactly the failing-recovery path the cooldown is meant to bound. Route the rollback write on the 401 path through the notification suppression recovery already uses for its own writes, so a rollback no longer clears the cooldown. The native-client-event recovery path keeps notifying, since there the rollback notification is load-bearing: it queues the native refresh that pushes the restored token back to the native module. --- .../ClerkProvider.nativeClientSync.test.tsx | 50 +++++++++++++++++++ .../expo/src/provider/nativeClientSync.tsx | 14 ++++++ 2 files changed, 64 insertions(+) diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index ee529985ab3..cde4075ff02 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -1328,6 +1328,56 @@ describe('ClerkProvider native client sync', () => { expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); }); + test('keeps the cooldown when a failed recovery rolls the device token back', async () => { + const session = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; + const fetchClient = vi.fn().mockRejectedValue(new Error('stale session 401')); + + mocks.clerkInstance.client = { + id: 'client_1', + signedInSessions: [session], + lastActiveSessionId: 'session_1', + fetch: fetchClient, + }; + mocks.clerkInstance.session = session; + // Cached token A differs from native token B, so the rollback write changes the cached value. + mocks.tokenCache.getToken.mockResolvedValue('cached-token-A'); + mocks.getClientToken.mockResolvedValue('native-token-B'); + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); + }); + + fetchClient.mockClear(); + + await act(async () => { + await mocks.clerkInstance.handleUnauthenticated(); + }); + expect(fetchClient).toHaveBeenCalledTimes(1); + + await act(async () => { + await mocks.clerkInstance.handleUnauthenticated(); + }); + + // The rollback is internal recovery, not an external rotation, so the second 401 delegates to core. + expect(fetchClient).toHaveBeenCalledTimes(1); + expect(originalHandleUnauthenticated).toHaveBeenCalledTimes(1); + }); + test('refreshes native from the server after the JS client changes', async () => { mocks.tokenCache.getToken.mockResolvedValue(null); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 7a4eea73dec..416e89c826a 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -263,6 +263,7 @@ async function refreshJsClientFromNativeState({ rejectForeignSessionlessClient = false, reloadInitialResources, shouldSyncDeviceToken = true, + suppressDeviceTokenRollbackNotification = false, suppressTokenCacheNotificationsRef, tokenCache, }: { @@ -272,6 +273,7 @@ async function refreshJsClientFromNativeState({ rejectForeignSessionlessClient?: boolean; reloadInitialResources: boolean; shouldSyncDeviceToken?: boolean; + suppressDeviceTokenRollbackNotification?: boolean; suppressTokenCacheNotificationsRef?: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { @@ -282,6 +284,17 @@ async function refreshJsClientFromNativeState({ return; } + // On the 401 path a rollback is part of recovery, not an external rotation, so it must not + // reopen the cooldown. The native-event path still notifies so native resyncs the restored token. + if (suppressDeviceTokenRollbackNotification) { + await syncNativeDeviceTokenToCache({ + deviceToken: previousDeviceToken, + suppressTokenCacheNotificationsRef, + tokenCache, + }); + return; + } + await syncDeviceTokenToCache(tokenCache, previousDeviceToken); }; @@ -810,6 +823,7 @@ export function NativeClientSync({ previousDeviceToken, rejectForeignSessionlessClient: true, reloadInitialResources: false, + suppressDeviceTokenRollbackNotification: true, suppressTokenCacheNotificationsRef, tokenCache, }); From 5a90ff708e20c92b1a7a583fda06b9308cd3e7a9 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 7 Aug 2026 12:42:27 +0300 Subject: [PATCH 3/7] fix(expo): close native recovery gaps around the 401 cooldown The suppressed rollback write skips the token-cache listener that used to resync native, so rejecting a foreign client left native holding the rejected token. The reject branch now pushes the restored token to native directly. The error branch stays cache-only since its second-chance recovery re-adopts the native token anyway. The cooldown stamp moves to when the attempt settles, so a slow recovery no longer finishes with a mostly spent window. A rotation landing mid-attempt still clears the stamp and forces a fresh attempt, and a backwards clock jump counts as expired instead of waiting out the gap. Persisting the dummy client snapshot made the next boot see a populated cache and skip recovery. The save listener now skips the dummy, and a previously persisted dummy is treated as missing on load. --- .../ClerkProvider.nativeClientSync.test.tsx | 51 ++++++++++ .../expo/src/provider/nativeClientSync.tsx | 99 ++++++++++++------- .../__tests__/createClerkInstance.test.ts | 66 +++++++++++++ .../provider/singleton/createClerkInstance.ts | 7 +- 4 files changed, 184 insertions(+), 39 deletions(-) diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index cde4075ff02..cbc8ff67fae 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -1378,6 +1378,57 @@ describe('ClerkProvider native client sync', () => { expect(originalHandleUnauthenticated).toHaveBeenCalledTimes(1); }); + test('pushes the restored device token back to native when recovery rejects a foreign client', async () => { + const activeSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const foreignClient = { + id: 'client_foreign', + signedInSessions: [], + lastActiveSessionId: null, + }; + const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; + + mocks.clerkInstance.client = { + id: 'client_js', + signedInSessions: [activeSession], + lastActiveSessionId: activeSession.id, + fetch: vi.fn().mockResolvedValue(foreignClient), + }; + mocks.clerkInstance.session = activeSession; + mocks.tokenCache.getToken.mockResolvedValue('js-device-token'); + mocks.getClientToken.mockResolvedValue('js-device-token'); + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); + }); + + // Native rotates to a foreign token between bootstrap and the 401. + mocks.getClientToken.mockResolvedValue('native-device-token'); + mocks.syncClientStateFromJs.mockClear(); + + await act(async () => { + await mocks.clerkInstance.handleUnauthenticated(); + }); + + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('js-device-token', expect.any(String), false, true); + }); + expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); + }); + test('refreshes native from the server after the JS client changes', async () => { mocks.tokenCache.getToken.mockResolvedValue(null); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 416e89c826a..6989094d453 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -259,6 +259,7 @@ function isForeignSessionlessClient(previousSnapshot: ClientStateSnapshot, refre async function refreshJsClientFromNativeState({ clerkInstance, nativeDeviceToken, + nativeRefreshFromJsControllerRef, previousDeviceToken, rejectForeignSessionlessClient = false, reloadInitialResources, @@ -269,6 +270,7 @@ async function refreshJsClientFromNativeState({ }: { clerkInstance: SyncableClerkInstance; nativeDeviceToken: string | null; + nativeRefreshFromJsControllerRef?: MutableRefObject; previousDeviceToken?: string | null; rejectForeignSessionlessClient?: boolean; reloadInitialResources: boolean; @@ -317,6 +319,11 @@ async function refreshJsClientFromNativeState({ if (refreshedClient) { if (rejectForeignSessionlessClient && isForeignSessionlessClient(previousClientSnapshot, refreshedClient)) { await restorePreviousDeviceToken(); + // The suppressed rollback write skips the listener that resyncs native, and JS keeps the + // restored client here, so native must be told about the restored token directly. + if (suppressDeviceTokenRollbackNotification && shouldSyncDeviceToken && previousDeviceToken !== undefined) { + nativeRefreshFromJsControllerRef?.current?.syncDeviceTokenToNative(previousDeviceToken); + } const restoredClient = previousClientSnapshot.restore?.(); if (restoredClient) { clerkInstance.updateClient?.(restoredClient); @@ -803,47 +810,59 @@ export function NativeClientSync({ isHandlingUnauthenticated = true; try { // Re-reading native state and refetching the client for every response in a 401 burst only amplifies it. - const now = Date.now(); const lastRecovery = lastUnauthenticatedRecoveryRef.current; - if (lastRecovery !== undefined && now - lastRecovery < unauthenticatedRecoveryCooldownMs) { - return await originalHandleUnauthenticated(options); + if (lastRecovery !== undefined) { + const elapsed = Date.now() - lastRecovery; + // A backwards clock jump makes elapsed negative; treat it as expired instead of waiting out the gap. + if (elapsed >= 0 && elapsed < unauthenticatedRecoveryCooldownMs) { + return await originalHandleUnauthenticated(options); + } } - lastUnauthenticatedRecoveryRef.current = now; + lastUnauthenticatedRecoveryRef.current = Date.now(); - return await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { - try { - const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); - const previousDeviceToken = await getCachedDeviceToken(tokenCache); - // Native may have already moved the server-side client to a new - // active session. Refresh JS before allowing Clerk JS' stale-session - // 401 path to collapse the whole client to signed out. - const didRecover = await refreshJsClientFromNativeState({ - clerkInstance, - nativeDeviceToken, - previousDeviceToken, - rejectForeignSessionlessClient: true, - reloadInitialResources: false, - suppressDeviceTokenRollbackNotification: true, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - if (didRecover) { - return; - } - } catch (error) { - const didRecover = await recoverJsClientFromNativeDeviceToken({ - clerkInstance, - error, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - if (didRecover) { - return; + try { + return await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { + try { + const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); + const previousDeviceToken = await getCachedDeviceToken(tokenCache); + // Native may have already moved the server-side client to a new + // active session. Refresh JS before allowing Clerk JS' stale-session + // 401 path to collapse the whole client to signed out. + const didRecover = await refreshJsClientFromNativeState({ + clerkInstance, + nativeDeviceToken, + nativeRefreshFromJsControllerRef, + previousDeviceToken, + rejectForeignSessionlessClient: true, + reloadInitialResources: false, + suppressDeviceTokenRollbackNotification: true, + suppressTokenCacheNotificationsRef, + tokenCache, + }); + if (didRecover) { + return; + } + } catch (error) { + const didRecover = await recoverJsClientFromNativeDeviceToken({ + clerkInstance, + error, + suppressTokenCacheNotificationsRef, + tokenCache, + }); + if (didRecover) { + return; + } } - } - return originalHandleUnauthenticated(options); - }); + return originalHandleUnauthenticated(options); + }); + } finally { + // Slow attempts must not finish with a mostly spent window, so the stamp moves to settle + // time. A rotation mid-attempt cleared the ref to force a fresh attempt; keep it cleared. + if (lastUnauthenticatedRecoveryRef.current !== undefined) { + lastUnauthenticatedRecoveryRef.current = Date.now(); + } + } } finally { isHandlingUnauthenticated = false; } @@ -856,7 +875,13 @@ export function NativeClientSync({ clerkInstance.handleUnauthenticated = originalHandleUnauthenticated; } }; - }, [clerkInstance, suppressJsClientChangedRef, suppressTokenCacheNotificationsRef, tokenCache]); + }, [ + clerkInstance, + nativeRefreshFromJsControllerRef, + suppressJsClientChangedRef, + suppressTokenCacheNotificationsRef, + tokenCache, + ]); useEffect(() => { if (!clerkInstance || typeof clerkInstance.addListener !== 'function') { diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index 8ece24b5646..70807e586b0 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -516,6 +516,72 @@ describe('createClerkInstance', () => { expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1); }); + test('treats a persisted dummy client as missing', async () => { + mocks.requestInitialResources.mockImplementation(() => new Promise(() => {})); + + const cachedEnvironment = { + object: 'environment', + id: 'env_cached', + auth_config: { object: 'auth_config', id: 'aac_cached', session_minter: true }, + }; + + const createClerkInstance = await loadCreateClerkInstance(); + const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); + const clerk = getClerkInstance({ + publishableKey: 'pk_test_123', + __experimental_resourceCache: () => ({ + get: (key: string) => { + if (key.startsWith('__clerk_cache_environment')) { + return Promise.resolve(JSON.stringify(cachedEnvironment)); + } + if (key.startsWith('__clerk_cache_client')) { + return Promise.resolve(JSON.stringify(DUMMY_CLERK_CLIENT_RESOURCE)); + } + return Promise.resolve(null); + }, + set: () => Promise.resolve(), + }), + }) as unknown as MockClerk; + + const resources = await clerk.__internal_getCachedResources?.(); + + expect(resources?.environment).toEqual(cachedEnvironment); + expect(resources?.client).toMatchObject({ id: DUMMY_CLERK_CLIENT_RESOURCE.id }); + + await vi.advanceTimersByTimeAsync(3_000); + expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1); + }); + + test('does not persist the dummy client snapshot', async () => { + const set = vi.fn(() => Promise.resolve()); + + const createClerkInstance = await loadCreateClerkInstance(); + const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); + const clerk = getClerkInstance({ + publishableKey: 'pk_test_123', + __experimental_resourceCache: () => ({ + get: () => Promise.resolve(null), + set, + }), + }) as unknown as MockClerk; + + const listener = clerk.addListener.mock.calls[0]?.[0] as (payload: { client: unknown }) => void; + expect(listener).toBeTypeOf('function'); + + listener({ client: { id: DUMMY_CLERK_CLIENT_RESOURCE.id } }); + expect(set).not.toHaveBeenCalled(); + + listener({ + client: { + id: 'client_real', + lastActiveSessionId: null, + signedInSessions: [], + __internal_toSnapshot: () => ({ id: 'client_real' }), + }, + }); + expect(set).toHaveBeenCalledWith(expect.stringContaining('__clerk_cache_client'), expect.any(String)); + }); + test('stops recovering after the initial resources load', async () => { mocks.requestInitialResources.mockResolvedValue(undefined); diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index c9f15ab51cf..624a164b8cf 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -242,7 +242,8 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { void EnvironmentResourceCache.save(environment.__internal_toSnapshot()); } - if (client) { + // Persisting the dummy would make the next boot see a populated cache and skip recovery. + if (client && client.id !== DUMMY_CLERK_CLIENT_RESOURCE.id) { void ClientResourceCache.save(client.__internal_toSnapshot()); if (client.lastActiveSessionId) { const currentSession = client.signedInSessions.find(s => s.id === client.lastActiveSessionId); @@ -261,7 +262,9 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { environment: EnvironmentJSONSnapshot | null; }> => { const environment = await EnvironmentResourceCache.load(); - const client = await ClientResourceCache.load(); + const cachedClient = await ClientResourceCache.load(); + // Installs that persisted the dummy before the save guard existed must still recover. + const client = cachedClient?.id === DUMMY_CLERK_CLIENT_RESOURCE.id ? null : cachedClient; if (!environment || !client) { scheduleResourceRetry(3000); } From b6a7f832e5ac5d2eeb0f8e49729e7a3a630ed8d3 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 7 Aug 2026 12:42:56 +0300 Subject: [PATCH 4/7] chore(clerk-js): bump the native bundle limit for the minter token guard --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 95614c3de45..258090362d7 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "75KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" }, - { "path": "./dist/clerk.native.js", "maxSize": "74KB" }, + { "path": "./dist/clerk.native.js", "maxSize": "74.5KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, { "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" }, From be1d6d5c0340c570692b9daec144f1cc692d6cc8 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 7 Aug 2026 14:32:13 +0300 Subject: [PATCH 5/7] fix(expo): keep the session-JWT wipe and cover the dummy environment The dummy-client save guard skipped the whole listener block, including the SessionJWTCache.remove() that wipes the offline JWT fallback on a sessionless emission. getToken falls back to that cache on network errors regardless of which client is active, so the wipe is load-bearing and is now restored for dummy emissions. The environment cache had the symmetric hole the client guard closed: an offline boot with only the environment missing persisted the dummy environment snapshot, and the next boot saw both caches populated and never scheduled recovery. The save listener now skips dummy environment snapshots and a previously persisted one is treated as missing on load. --- .../__tests__/createClerkInstance.test.ts | 85 ++++++++++++++++++- .../provider/singleton/createClerkInstance.ts | 21 +++-- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index 70807e586b0..c2c561585f0 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -2,7 +2,7 @@ import type { Clerk } from '@clerk/clerk-js'; import { ClerkRuntimeError } from '@clerk/shared/error'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { DUMMY_CLERK_CLIENT_RESOURCE } from '../../../cache'; +import { DUMMY_CLERK_CLIENT_RESOURCE, DUMMY_CLERK_ENVIRONMENT_RESOURCE } from '../../../cache'; import type { TokenCache } from '../../../cache/types'; import { CLERK_CLIENT_JWT_KEY } from '../../../constants'; @@ -552,7 +552,7 @@ describe('createClerkInstance', () => { expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1); }); - test('does not persist the dummy client snapshot', async () => { + test('does not persist the dummy client snapshot but still clears the cached session JWT', async () => { const set = vi.fn(() => Promise.resolve()); const createClerkInstance = await loadCreateClerkInstance(); @@ -568,8 +568,10 @@ describe('createClerkInstance', () => { const listener = clerk.addListener.mock.calls[0]?.[0] as (payload: { client: unknown }) => void; expect(listener).toBeTypeOf('function'); - listener({ client: { id: DUMMY_CLERK_CLIENT_RESOURCE.id } }); - expect(set).not.toHaveBeenCalled(); + listener({ client: { id: DUMMY_CLERK_CLIENT_RESOURCE.id, lastActiveSessionId: null } }); + expect(set).not.toHaveBeenCalledWith(expect.stringContaining('__clerk_cache_client'), expect.any(String)); + // A sessionless emission, dummy or not, still wipes the offline JWT fallback. + expect(set).toHaveBeenCalledWith(expect.stringContaining('__clerk_cache_session_jwt'), ''); listener({ client: { @@ -582,6 +584,81 @@ describe('createClerkInstance', () => { expect(set).toHaveBeenCalledWith(expect.stringContaining('__clerk_cache_client'), expect.any(String)); }); + test('does not persist the dummy environment snapshot', async () => { + const set = vi.fn(() => Promise.resolve()); + + const createClerkInstance = await loadCreateClerkInstance(); + const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); + const clerk = getClerkInstance({ + publishableKey: 'pk_test_123', + __experimental_resourceCache: () => ({ + get: () => Promise.resolve(null), + set, + }), + }) as unknown as MockClerk; + + (clerk as unknown as { __internal_environment: unknown }).__internal_environment = { + __internal_toSnapshot: () => ({ + object: 'environment', + id: '', + display_config: { id: DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id }, + }), + }; + + const listener = clerk.addListener.mock.calls[0]?.[0] as (payload: { client: unknown }) => void; + listener({ client: null }); + expect(set).not.toHaveBeenCalledWith(expect.stringContaining('__clerk_cache_environment'), expect.any(String)); + + (clerk as unknown as { __internal_environment: unknown }).__internal_environment = { + __internal_toSnapshot: () => ({ + object: 'environment', + id: 'env_real', + display_config: { id: 'display_config_real' }, + }), + }; + listener({ client: null }); + expect(set).toHaveBeenCalledWith(expect.stringContaining('__clerk_cache_environment'), expect.any(String)); + }); + + test('treats a persisted dummy environment as missing', async () => { + mocks.requestInitialResources.mockImplementation(() => new Promise(() => {})); + + const cachedClient = { object: 'client', id: 'client_cached', sessions: [] }; + const dummyEnvironmentSnapshot = { + object: 'environment', + id: '', + display_config: { id: DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id }, + }; + + const createClerkInstance = await loadCreateClerkInstance(); + const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); + const clerk = getClerkInstance({ + publishableKey: 'pk_test_123', + __experimental_resourceCache: () => ({ + get: (key: string) => { + if (key.startsWith('__clerk_cache_environment')) { + return Promise.resolve(JSON.stringify(dummyEnvironmentSnapshot)); + } + if (key.startsWith('__clerk_cache_client')) { + return Promise.resolve(JSON.stringify(cachedClient)); + } + return Promise.resolve(null); + }, + set: () => Promise.resolve(), + }), + }) as unknown as MockClerk; + + const resources = await clerk.__internal_getCachedResources?.(); + + expect(resources?.client).toEqual(cachedClient); + expect(resources?.environment).toMatchObject({ + auth_config: { session_minter: false }, + }); + + await vi.advanceTimersByTimeAsync(3_000); + expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1); + }); + test('stops recovering after the initial resources load', async () => { mocks.requestInitialResources.mockResolvedValue(undefined); diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index 624a164b8cf..567ddd5715d 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -238,13 +238,18 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { __internal_clerk.addListener(({ client }) => { // @ts-expect-error - This is an internal API const environment = __internal_clerk?.__internal_environment as EnvironmentResource; + // Persisting a dummy would make the next boot see a populated cache and skip recovery. if (environment) { - void EnvironmentResourceCache.save(environment.__internal_toSnapshot()); + const environmentSnapshot = environment.__internal_toSnapshot(); + if (environmentSnapshot.display_config?.id !== DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id) { + void EnvironmentResourceCache.save(environmentSnapshot); + } } - // Persisting the dummy would make the next boot see a populated cache and skip recovery. - if (client && client.id !== DUMMY_CLERK_CLIENT_RESOURCE.id) { - void ClientResourceCache.save(client.__internal_toSnapshot()); + if (client) { + if (client.id !== DUMMY_CLERK_CLIENT_RESOURCE.id) { + void ClientResourceCache.save(client.__internal_toSnapshot()); + } if (client.lastActiveSessionId) { const currentSession = client.signedInSessions.find(s => s.id === client.lastActiveSessionId); const token = currentSession?.lastActiveToken?.getRawString(); @@ -261,9 +266,13 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null; }> => { - const environment = await EnvironmentResourceCache.load(); + const cachedEnvironment = await EnvironmentResourceCache.load(); const cachedClient = await ClientResourceCache.load(); - // Installs that persisted the dummy before the save guard existed must still recover. + // Installs that persisted a dummy before the save guard existed must still recover. + const environment = + cachedEnvironment?.display_config?.id === DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id + ? null + : cachedEnvironment; const client = cachedClient?.id === DUMMY_CLERK_CLIENT_RESOURCE.id ? null : cachedClient; if (!environment || !client) { scheduleResourceRetry(3000); From 0596a868b4b0ef7a5e34c457882e78b408d54f55 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 7 Aug 2026 15:50:54 +0300 Subject: [PATCH 6/7] chore: retrigger ci From 5bff88b59026451c0238793147b78d3af5def017 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 7 Aug 2026 16:18:02 +0300 Subject: [PATCH 7/7] refactor(clerk-js,expo): shrink the minter hardening surface shouldKeepExistingLastActiveToken moves to tokenFreshness next to the primitives it composes, and Client.fromJSON assigns the carried-forward token directly, dropping the one-caller __internal method. The redundant normalizeOrgId wrap goes; tokenOrgId already returns an empty string. refreshJsClientFromNativeState reports 'refreshed' or 'restored' instead of taking a controller ref, so the 401 caller owns the restored-token push to native and the shared helper stays policy-free. The rollback write branch collapses into one syncNativeDeviceTokenToCache call. Dummy detection gets isDummyClient/isDummyEnvironment predicates beside the constants; the environment guard now runs before the snapshot it used to discard. Tests: the 401 foreign-client push assertion folds into the existing recovery test, the piggyback guard is covered by a direct Client.fromJSON test instead of 45 lines of fake-clerk wiring, and the three hand-rolled resource-cache stubs become one. --- .../clerk-js/src/core/resources/Client.ts | 10 ++- .../clerk-js/src/core/resources/Session.ts | 35 +--------- .../core/resources/__tests__/Session.test.ts | 63 ++++------------- packages/clerk-js/src/core/tokenFreshness.ts | 17 +++++ packages/expo/src/cache/dummy-data/index.ts | 18 ++++- packages/expo/src/cache/index.ts | 7 +- .../ClerkProvider.nativeClientSync.test.tsx | 56 ++------------- .../expo/src/provider/nativeClientSync.tsx | 42 +++++------ .../__tests__/createClerkInstance.test.ts | 69 +++++++++---------- .../provider/singleton/createClerkInstance.ts | 18 ++--- 10 files changed, 124 insertions(+), 211 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 68156cd1907..899600f1b8d 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -12,6 +12,7 @@ import { unixEpochToDate } from '../../utils/date'; import { eventBus } from '../events'; import type { FapiResponseJSON } from '../fapiClient'; import { SessionTokenCache } from '../tokenCache'; +import { shouldKeepExistingLastActiveToken } from '../tokenFreshness'; import { BaseResource, Session, SignIn, SignUp } from './internal'; export function getClientResourceFromPayload(responseJSON: FapiResponseJSON | null): ClientResource | undefined { @@ -145,7 +146,14 @@ export class Client extends BaseResource implements ClientResource { const previousTokens = new Map(this.sessions.map(session => [session.id, session.lastActiveToken])); this.sessions = (data.sessions || []).map(s => { const session = new Session(s); - session.__internal_keepFreshestLastActiveToken(previousTokens.get(session.id)); + const previousToken = previousTokens.get(session.id); + if ( + previousToken && + session.lastActiveToken && + shouldKeepExistingLastActiveToken(previousToken, session.lastActiveToken) + ) { + session.lastActiveToken = previousToken; + } return session; }); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index f4f262ed2de..981a30a6f6c 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -51,30 +51,13 @@ import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../e import { eventBus, events } from '../events'; import type { FapiResponseJSON } from '../fapiClient'; import { SessionTokenCache } from '../tokenCache'; -import { normalizeOrgId, pickFreshestJwt, tokenOrgId, tokenSid } from '../tokenFreshness'; +import { shouldKeepExistingLastActiveToken } from '../tokenFreshness'; import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; const focusedRefresh = (onRefresh: () => void): { onRefresh?: () => void } => isTabFocused() === false ? {} : { onRefresh }; -// Mirrors the cookie guard: only a same session+org lastActiveToken is a comparable -// freshness baseline, so a session or org switch always adopts the incoming token. -// Without this, an org-switch token minted by a stale edge (lower oiat) would lose -// to the previous org's token and pin lastActiveToken to the old org's claims. -function shouldKeepExistingLastActiveToken(current: TokenResource | null | undefined, incoming: TokenResource) { - if (!current?.jwt) { - return false; - } - if ( - tokenSid(current) !== tokenSid(incoming) || - normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming)) - ) { - return false; - } - return pickFreshestJwt(current, incoming) !== incoming; -} - export class Session extends BaseResource implements SessionResource { pathRoot = '/client/sessions'; @@ -428,7 +411,6 @@ export class Session extends BaseResource implements SessionResource { this.publicUserData = new PublicUserData(data.public_user_data); } - // Responses are applied in place on a live session, so a piggybacked token can be staler than the one held. const incomingLastActiveToken = data.last_active_token ? new Token(data.last_active_token) : null; if (!incomingLastActiveToken || !shouldKeepExistingLastActiveToken(this.lastActiveToken, incomingLastActiveToken)) { this.lastActiveToken = incomingLastActiveToken; @@ -555,21 +537,6 @@ export class Session extends BaseResource implements SessionResource { } } - /** - * Carries a token forward from the Session instance this one replaces. A client payload rebuilds - * every session object, so without this a stale piggybacked token becomes the next mint seed. - * - * @internal - */ - public __internal_keepFreshestLastActiveToken(previous: TokenResource | null | undefined): void { - if (!previous || !this.lastActiveToken) { - return; - } - if (shouldKeepExistingLastActiveToken(previous, this.lastActiveToken)) { - this.lastActiveToken = previous; - } - } - #fetchToken( template: string | undefined, organizationId: string | undefined | null, diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 6e017fe94f6..33ce91597e1 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -2495,69 +2495,30 @@ describe('Session', () => { expect(session.lastActiveToken).toBeNull(); }); - it('constructor hydration adopts the token in the payload', () => { + it('a stale piggybacked client payload does not regress the rebuilt session token', () => { const high = createJwtWithOiat(NOW, NOW + 30); const low = createJwtWithOiat(NOW, NOW); + const higher = createJwtWithOiat(NOW, NOW + 60); - expect( - makeSession({ last_active_token: tokenJSON(high) } as Partial).lastActiveToken?.getRawString(), - ).toBe(high); - expect( - makeSession({ last_active_token: tokenJSON(low) } as Partial).lastActiveToken?.getRawString(), - ).toBe(low); - }); - - it('a stale piggybacked client does not regress the active session token', async () => { - // Exercises the real path: fapi response -> _updateClient -> Client.fromJSON rebuild. - fetchSpy.mockRestore(); - - const high = createJwtWithOiat(NOW, NOW + 30); - const low = createJwtWithOiat(NOW, NOW); - - const sessionJSON = (jwt: string) => - ({ - status: 'active', - id: 'session_1', - object: 'session', - user: createUser({}), - last_active_organization_id: null, - actor: null, - created_at: Date.now(), - updated_at: Date.now(), - last_active_token: tokenJSON(jwt), - }) as unknown as SessionJSON; - - const clientJSON = (jwt: string) => + const clientJSON = (lastActiveToken: ReturnType | null) => ({ object: 'client', id: 'client_1', last_active_session_id: 'session_1', - sessions: [sessionJSON(jwt)], + sessions: [touchResponse(lastActiveToken).response], }) as any; - const client = Client.getOrCreateInstance().fromJSON(clientJSON(high)); - const clerk: any = { - __internal_environment: { authConfig: { sessionMinter: true } }, - client, - session: client.sessions[0], - getFapiClient: () => ({ - request: vi.fn().mockResolvedValue({ - status: 200, - payload: { response: sessionJSON(low), client: clientJSON(low) }, - }), - }), - }; - clerk.updateClient = (newClient: any) => { - clerk.client = newClient; - clerk.session = newClient.sessions.find((s: Session) => s.id === newClient.lastActiveSessionId); - }; - BaseResource.clerk = clerk; + const client = Client.getOrCreateInstance().fromJSON(clientJSON(tokenJSON(high))); + expect(client.sessions[0]?.lastActiveToken?.getRawString()).toBe(high); - expect(clerk.session.lastActiveToken?.getRawString()).toBe(high); + client.fromJSON(clientJSON(tokenJSON(low))); + expect(client.sessions[0]?.lastActiveToken?.getRawString()).toBe(high); - await clerk.session.touch(); + client.fromJSON(clientJSON(tokenJSON(higher))); + expect(client.sessions[0]?.lastActiveToken?.getRawString()).toBe(higher); - expect(clerk.session.lastActiveToken?.getRawString()).toBe(high); + client.fromJSON(clientJSON(null)); + expect(client.sessions[0]?.lastActiveToken).toBeNull(); }); }); }); diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index 73a060462fd..997135f9be1 100644 --- a/packages/clerk-js/src/core/tokenFreshness.ts +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -72,3 +72,20 @@ export function tokenOrgId(input: TokenResource | JWT): string { export function normalizeOrgId(orgId?: string | null): string { return orgId || ''; } + +// Mirrors the cookie guard: only a same session+org lastActiveToken is a comparable +// freshness baseline, so a session or org switch always adopts the incoming token. +// Without this, an org-switch token minted by a stale edge (lower oiat) would lose +// to the previous org's token and pin lastActiveToken to the old org's claims. +export function shouldKeepExistingLastActiveToken( + current: TokenResource | null | undefined, + incoming: TokenResource, +): boolean { + if (!current?.jwt) { + return false; + } + if (tokenSid(current) !== tokenSid(incoming) || tokenOrgId(current) !== tokenOrgId(incoming)) { + return false; + } + return pickFreshestJwt(current, incoming) !== incoming; +} diff --git a/packages/expo/src/cache/dummy-data/index.ts b/packages/expo/src/cache/dummy-data/index.ts index de09f6e5a52..c18318f586a 100644 --- a/packages/expo/src/cache/dummy-data/index.ts +++ b/packages/expo/src/cache/dummy-data/index.ts @@ -1,2 +1,16 @@ -export { DUMMY_CLERK_CLIENT_RESOURCE } from './client-resource'; -export { DUMMY_CLERK_ENVIRONMENT_RESOURCE } from './environment-resource'; +import { DUMMY_CLERK_CLIENT_RESOURCE } from './client-resource'; +import { DUMMY_CLERK_ENVIRONMENT_RESOURCE } from './environment-resource'; + +export { DUMMY_CLERK_CLIENT_RESOURCE, DUMMY_CLERK_ENVIRONMENT_RESOURCE }; + +export function isDummyClient(client: { id?: string | null } | null | undefined): boolean { + return client?.id === DUMMY_CLERK_CLIENT_RESOURCE.id; +} + +// The dummy environment's own id is empty, so its display_config id is the reliable marker. +export function isDummyEnvironment( + environment: { display_config?: { id?: string } | null; displayConfig?: { id?: string } | null } | null | undefined, +): boolean { + const id = environment?.display_config?.id ?? environment?.displayConfig?.id; + return id === DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id; +} diff --git a/packages/expo/src/cache/index.ts b/packages/expo/src/cache/index.ts index a83a3b7cc5f..c7ec0186a5a 100644 --- a/packages/expo/src/cache/index.ts +++ b/packages/expo/src/cache/index.ts @@ -2,4 +2,9 @@ export type { TokenCache } from './types'; export { MemoryTokenCache } from './MemoryTokenCache'; export { ClientResourceCache, EnvironmentResourceCache, SessionJWTCache } from './ResourceCache'; -export { DUMMY_CLERK_ENVIRONMENT_RESOURCE, DUMMY_CLERK_CLIENT_RESOURCE } from './dummy-data'; +export { + DUMMY_CLERK_ENVIRONMENT_RESOURCE, + DUMMY_CLERK_CLIENT_RESOURCE, + isDummyClient, + isDummyEnvironment, +} from './dummy-data'; diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index cbc8ff67fae..9fd401d06ae 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -1378,57 +1378,6 @@ describe('ClerkProvider native client sync', () => { expect(originalHandleUnauthenticated).toHaveBeenCalledTimes(1); }); - test('pushes the restored device token back to native when recovery rejects a foreign client', async () => { - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const foreignClient = { - id: 'client_foreign', - signedInSessions: [], - lastActiveSessionId: null, - }; - const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - - mocks.clerkInstance.client = { - id: 'client_js', - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - fetch: vi.fn().mockResolvedValue(foreignClient), - }; - mocks.clerkInstance.session = activeSession; - mocks.tokenCache.getToken.mockResolvedValue('js-device-token'); - mocks.getClientToken.mockResolvedValue('js-device-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); - }); - - // Native rotates to a foreign token between bootstrap and the 401. - mocks.getClientToken.mockResolvedValue('native-device-token'); - mocks.syncClientStateFromJs.mockClear(); - - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('js-device-token', expect.any(String), false, true); - }); - expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); - }); - test('refreshes native from the server after the JS client changes', async () => { mocks.tokenCache.getToken.mockResolvedValue(null); @@ -1915,6 +1864,7 @@ describe('ClerkProvider native client sync', () => { mocks.getClientToken.mockResolvedValue('ghost-device-token'); mocks.tokenCache.saveToken.mockClear(); + mocks.syncClientStateFromJs.mockClear(); await act(async () => { await mocks.clerkInstance.handleUnauthenticated(); @@ -1925,6 +1875,10 @@ describe('ClerkProvider native client sync', () => { expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'ghost-device-token'); expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'js-device-token'); + // The rollback write is notification-suppressed, so the restored token reaches native via a direct push. + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('js-device-token', expect.any(String), false, true); + }); }); test('skips native adoption when the cached device token read times out while signed in', async () => { diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 6989094d453..5c5bca2c978 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -259,7 +259,6 @@ function isForeignSessionlessClient(previousSnapshot: ClientStateSnapshot, refre async function refreshJsClientFromNativeState({ clerkInstance, nativeDeviceToken, - nativeRefreshFromJsControllerRef, previousDeviceToken, rejectForeignSessionlessClient = false, reloadInitialResources, @@ -270,7 +269,6 @@ async function refreshJsClientFromNativeState({ }: { clerkInstance: SyncableClerkInstance; nativeDeviceToken: string | null; - nativeRefreshFromJsControllerRef?: MutableRefObject; previousDeviceToken?: string | null; rejectForeignSessionlessClient?: boolean; reloadInitialResources: boolean; @@ -278,7 +276,7 @@ async function refreshJsClientFromNativeState({ suppressDeviceTokenRollbackNotification?: boolean; suppressTokenCacheNotificationsRef?: MutableRefObject; tokenCache: TokenCache | undefined; -}): Promise { +}): Promise { const previousClientSnapshot = snapshotClientState(clerkInstance.client); const restorePreviousDeviceToken = async () => { @@ -288,16 +286,13 @@ async function refreshJsClientFromNativeState({ // On the 401 path a rollback is part of recovery, not an external rotation, so it must not // reopen the cooldown. The native-event path still notifies so native resyncs the restored token. - if (suppressDeviceTokenRollbackNotification) { - await syncNativeDeviceTokenToCache({ - deviceToken: previousDeviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - return; - } - - await syncDeviceTokenToCache(tokenCache, previousDeviceToken); + await syncNativeDeviceTokenToCache({ + deviceToken: previousDeviceToken, + suppressTokenCacheNotificationsRef: suppressDeviceTokenRollbackNotification + ? suppressTokenCacheNotificationsRef + : undefined, + tokenCache, + }); }; let refreshedClient: ClientResource | null; @@ -319,11 +314,6 @@ async function refreshJsClientFromNativeState({ if (refreshedClient) { if (rejectForeignSessionlessClient && isForeignSessionlessClient(previousClientSnapshot, refreshedClient)) { await restorePreviousDeviceToken(); - // The suppressed rollback write skips the listener that resyncs native, and JS keeps the - // restored client here, so native must be told about the restored token directly. - if (suppressDeviceTokenRollbackNotification && shouldSyncDeviceToken && previousDeviceToken !== undefined) { - nativeRefreshFromJsControllerRef?.current?.syncDeviceTokenToNative(previousDeviceToken); - } const restoredClient = previousClientSnapshot.restore?.(); if (restoredClient) { clerkInstance.updateClient?.(restoredClient); @@ -331,14 +321,14 @@ async function refreshJsClientFromNativeState({ clerkInstance, }); } - return true; + return 'restored'; } clerkInstance.updateClient?.(refreshedClient); await reconcileJsActiveSessionFromClient({ clerkInstance, }); - return true; + return 'refreshed'; } if (reloadInitialResources && typeof clerkInstance.__internal_reloadInitialResources === 'function') { @@ -346,7 +336,7 @@ async function refreshJsClientFromNativeState({ await reconcileJsActiveSessionFromClient({ clerkInstance, }); - return Boolean(getDefaultSignedInSession(clerkInstance.client)); + return getDefaultSignedInSession(clerkInstance.client) ? 'refreshed' : false; } return false; @@ -828,10 +818,9 @@ export function NativeClientSync({ // Native may have already moved the server-side client to a new // active session. Refresh JS before allowing Clerk JS' stale-session // 401 path to collapse the whole client to signed out. - const didRecover = await refreshJsClientFromNativeState({ + const result = await refreshJsClientFromNativeState({ clerkInstance, nativeDeviceToken, - nativeRefreshFromJsControllerRef, previousDeviceToken, rejectForeignSessionlessClient: true, reloadInitialResources: false, @@ -839,7 +828,12 @@ export function NativeClientSync({ suppressTokenCacheNotificationsRef, tokenCache, }); - if (didRecover) { + // The suppressed rollback write skips the listener that resyncs native, so the + // restored token must be pushed to native from here. + if (result === 'restored' && previousDeviceToken !== undefined) { + nativeRefreshFromJsControllerRef.current?.syncDeviceTokenToNative(previousDeviceToken); + } + if (result) { return; } } catch (error) { diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index c2c561585f0..6b3324ae8a3 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -45,11 +45,28 @@ const createUnavailableResourceCache = () => ({ set: () => Promise.resolve(), }); -const createEnvironmentOnlyResourceCache = (environment: unknown) => () => ({ - get: (key: string) => - Promise.resolve(key.startsWith('__clerk_cache_environment') ? JSON.stringify(environment) : null), - set: () => Promise.resolve(), -}); +const createResourceCacheStub = + ({ + environment, + client, + set = () => Promise.resolve(), + }: { + environment?: unknown; + client?: unknown; + set?: (key: string, value: string) => Promise; + }) => + () => ({ + get: (key: string) => { + if (environment && key.startsWith('__clerk_cache_environment')) { + return Promise.resolve(JSON.stringify(environment)); + } + if (client && key.startsWith('__clerk_cache_client')) { + return Promise.resolve(JSON.stringify(client)); + } + return Promise.resolve(null); + }, + set, + }); const loadCreateClerkInstance = async () => { const mod = await import('../createClerkInstance'); @@ -503,7 +520,7 @@ describe('createClerkInstance', () => { const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); const clerk = getClerkInstance({ publishableKey: 'pk_test_123', - __experimental_resourceCache: createEnvironmentOnlyResourceCache(cachedEnvironment), + __experimental_resourceCache: createResourceCacheStub({ environment: cachedEnvironment }), }) as unknown as MockClerk; const resources = await clerk.__internal_getCachedResources?.(); @@ -529,17 +546,9 @@ describe('createClerkInstance', () => { const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); const clerk = getClerkInstance({ publishableKey: 'pk_test_123', - __experimental_resourceCache: () => ({ - get: (key: string) => { - if (key.startsWith('__clerk_cache_environment')) { - return Promise.resolve(JSON.stringify(cachedEnvironment)); - } - if (key.startsWith('__clerk_cache_client')) { - return Promise.resolve(JSON.stringify(DUMMY_CLERK_CLIENT_RESOURCE)); - } - return Promise.resolve(null); - }, - set: () => Promise.resolve(), + __experimental_resourceCache: createResourceCacheStub({ + environment: cachedEnvironment, + client: DUMMY_CLERK_CLIENT_RESOURCE, }), }) as unknown as MockClerk; @@ -559,10 +568,7 @@ describe('createClerkInstance', () => { const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); const clerk = getClerkInstance({ publishableKey: 'pk_test_123', - __experimental_resourceCache: () => ({ - get: () => Promise.resolve(null), - set, - }), + __experimental_resourceCache: createResourceCacheStub({ set }), }) as unknown as MockClerk; const listener = clerk.addListener.mock.calls[0]?.[0] as (payload: { client: unknown }) => void; @@ -591,13 +597,11 @@ describe('createClerkInstance', () => { const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); const clerk = getClerkInstance({ publishableKey: 'pk_test_123', - __experimental_resourceCache: () => ({ - get: () => Promise.resolve(null), - set, - }), + __experimental_resourceCache: createResourceCacheStub({ set }), }) as unknown as MockClerk; (clerk as unknown as { __internal_environment: unknown }).__internal_environment = { + displayConfig: { id: DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id }, __internal_toSnapshot: () => ({ object: 'environment', id: '', @@ -610,6 +614,7 @@ describe('createClerkInstance', () => { expect(set).not.toHaveBeenCalledWith(expect.stringContaining('__clerk_cache_environment'), expect.any(String)); (clerk as unknown as { __internal_environment: unknown }).__internal_environment = { + displayConfig: { id: 'display_config_real' }, __internal_toSnapshot: () => ({ object: 'environment', id: 'env_real', @@ -634,17 +639,9 @@ describe('createClerkInstance', () => { const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); const clerk = getClerkInstance({ publishableKey: 'pk_test_123', - __experimental_resourceCache: () => ({ - get: (key: string) => { - if (key.startsWith('__clerk_cache_environment')) { - return Promise.resolve(JSON.stringify(dummyEnvironmentSnapshot)); - } - if (key.startsWith('__clerk_cache_client')) { - return Promise.resolve(JSON.stringify(cachedClient)); - } - return Promise.resolve(null); - }, - set: () => Promise.resolve(), + __experimental_resourceCache: createResourceCacheStub({ + environment: dummyEnvironmentSnapshot, + client: cachedClient, }), }) as unknown as MockClerk; diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index 567ddd5715d..e1630787a22 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -15,6 +15,8 @@ import { DUMMY_CLERK_CLIENT_RESOURCE, DUMMY_CLERK_ENVIRONMENT_RESOURCE, EnvironmentResourceCache, + isDummyClient, + isDummyEnvironment, SessionJWTCache, } from '../../cache'; import { MemoryTokenCache } from '../../cache/MemoryTokenCache'; @@ -239,15 +241,12 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { // @ts-expect-error - This is an internal API const environment = __internal_clerk?.__internal_environment as EnvironmentResource; // Persisting a dummy would make the next boot see a populated cache and skip recovery. - if (environment) { - const environmentSnapshot = environment.__internal_toSnapshot(); - if (environmentSnapshot.display_config?.id !== DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id) { - void EnvironmentResourceCache.save(environmentSnapshot); - } + if (environment && !isDummyEnvironment(environment)) { + void EnvironmentResourceCache.save(environment.__internal_toSnapshot()); } if (client) { - if (client.id !== DUMMY_CLERK_CLIENT_RESOURCE.id) { + if (!isDummyClient(client)) { void ClientResourceCache.save(client.__internal_toSnapshot()); } if (client.lastActiveSessionId) { @@ -269,11 +268,8 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { const cachedEnvironment = await EnvironmentResourceCache.load(); const cachedClient = await ClientResourceCache.load(); // Installs that persisted a dummy before the save guard existed must still recover. - const environment = - cachedEnvironment?.display_config?.id === DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id - ? null - : cachedEnvironment; - const client = cachedClient?.id === DUMMY_CLERK_CLIENT_RESOURCE.id ? null : cachedClient; + const environment = isDummyEnvironment(cachedEnvironment) ? null : cachedEnvironment; + const client = isDummyClient(cachedClient) ? null : cachedClient; if (!environment || !client) { scheduleResourceRetry(3000); }