From a9808893cfc3a062c6ddb4a4aae283da773f7e9d Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:37:26 -0400 Subject: [PATCH 1/2] feat(expo): simplify native client sync engine Replace the JS <-> native client sync engine with one where native storage owns the single shared device token and each side refetches its own client on a payload-free "client changed" signal. Codes against the new ClerkExpo module contract (configureNative, getDeviceToken, setDeviceToken CAS, refreshClient, clerkNativeClientInvalidated). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../simplify-expo-native-client-sync.md | 5 + .../__tests__/reverification.test.ts | 5 +- .../__tests__/useBiometricCredentials.test.ts | 5 +- .../nativeReverification.ts | 6 +- .../__tests__/useNativeClientEvents.test.ts | 103 - .../__tests__/useSignInWithGoogle.test.ts | 7 +- .../expo/src/hooks/useNativeClientEvents.ts | 100 - packages/expo/src/provider/ClerkProvider.tsx | 66 +- .../ClerkProvider.nativeClientSync.test.tsx | 2397 ----------------- .../currentEngineAdapter.tsx | 169 -- .../nativeClientSyncBehavior/fakeClerk.ts | 21 +- .../nativeClientSyncBehavior/harness.ts | 3 + .../nativeClientSyncBehavior/index.ts | 2 +- .../nativeClientSync.behavior.test.tsx | 113 +- .../newEngineAdapter.tsx | 162 ++ .../nativeClientSyncCoordinator.test.ts | 266 -- .../expo/src/provider/nativeClientSync.ts | 336 +++ .../expo/src/provider/nativeClientSync.tsx | 1296 --------- .../provider/nativeClientSyncCoordinator.ts | 195 -- .../__tests__/createClerkInstance.test.ts | 27 + .../provider/singleton/createClerkInstance.ts | 14 +- .../src/specs/NativeClerkModule.android.ts | 12 +- packages/expo/src/specs/NativeClerkModule.ts | 12 +- .../src/utils/__tests__/native-module.test.ts | 9 +- packages/expo/src/utils/native-module.ts | 18 +- 25 files changed, 701 insertions(+), 4648 deletions(-) create mode 100644 .changeset/simplify-expo-native-client-sync.md delete mode 100644 packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts delete mode 100644 packages/expo/src/hooks/useNativeClientEvents.ts delete mode 100644 packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx delete mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncBehavior/currentEngineAdapter.tsx create mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncBehavior/newEngineAdapter.tsx delete mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts create mode 100644 packages/expo/src/provider/nativeClientSync.ts delete mode 100644 packages/expo/src/provider/nativeClientSync.tsx delete mode 100644 packages/expo/src/provider/nativeClientSyncCoordinator.ts diff --git a/.changeset/simplify-expo-native-client-sync.md b/.changeset/simplify-expo-native-client-sync.md new file mode 100644 index 00000000000..bb85e34b5db --- /dev/null +++ b/.changeset/simplify-expo-native-client-sync.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': minor +--- + +Rewrite the sync between the Clerk JS client and the native Clerk SDK. Both now share a single device token stored by the native SDK, and startup makes fewer requests. This fixes a stale token in the app's `tokenCache` signing out a session that was signed in natively, and the two SDKs ending up on different tokens when both rotated the device token at the same time. Requires the matching native module that ships with the next native SDK versions. diff --git a/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts b/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts index a66696b3d3e..0db30f9905d 100644 --- a/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts +++ b/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts @@ -23,10 +23,7 @@ vi.mock('../../utils/native-module', () => ({ }, })); vi.mock('../loadExpoBiometrics', () => ({ loadExpoBiometrics: mocks.loadExpoBiometrics })); -vi.mock('../../provider/nativeClientSyncCoordinator', () => ({ - waitForPendingJsToNativeSync: mocks.idle, - synchronizeNativeClientToJs: mocks.synchronize, -})); +vi.mock('../../provider/nativeClientSync', () => ({ idle: mocks.idle, pullFromNative: mocks.synchronize })); vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })); const session = { diff --git a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts index c976e11da3d..09f2dbefd4c 100644 --- a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts +++ b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts @@ -18,10 +18,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@clerk/react', () => ({ useClerk: mocks.useClerk })); vi.mock('../loadExpoBiometrics', () => ({ loadExpoBiometrics: mocks.loadExpoBiometrics })); -vi.mock('../../provider/nativeClientSyncCoordinator', () => ({ - waitForPendingJsToNativeSync: mocks.idle, - synchronizeNativeClientToJs: mocks.pullFromNative, -})); +vi.mock('../../provider/nativeClientSync', () => ({ idle: mocks.idle, pullFromNative: mocks.pullFromNative })); vi.mock('../../utils/native-module', () => ({ ClerkExpoModule: null })); vi.mock('react-native', () => ({ Platform: mocks.platform })); diff --git a/packages/expo/src/biometric-credentials/nativeReverification.ts b/packages/expo/src/biometric-credentials/nativeReverification.ts index 9418931a140..53f911fc771 100644 --- a/packages/expo/src/biometric-credentials/nativeReverification.ts +++ b/packages/expo/src/biometric-credentials/nativeReverification.ts @@ -1,6 +1,6 @@ import type { useClerk } from '@clerk/react'; -import { synchronizeNativeClientToJs, waitForPendingJsToNativeSync } from '../provider/nativeClientSyncCoordinator'; +import { idle, pullFromNative } from '../provider/nativeClientSync'; import { errorThrower } from '../utils/errors'; import { ClerkExpoModule } from '../utils/native-module'; import type { UseBiometricCredentialsReturn } from './types'; @@ -29,7 +29,7 @@ export function createNativeReverify(clerk: ReturnType): UseBio if (!session) { return errorThrower.throw('Biometric reverification requires an active session.'); } - await waitForPendingJsToNativeSync(); + await idle(); if (clerk.session?.id !== session.id) { return errorThrower.throw('The active session changed before biometric reverification started.'); } @@ -40,7 +40,7 @@ export function createNativeReverify(clerk: ReturnType): UseBio if (verification.status === 'complete') { session.clearCache(); } - await synchronizeNativeClientToJs(); + await pullFromNative(); const synchronizedSession = clerk.session; if (synchronizedSession?.id !== session.id) { return errorThrower.throw('The active session changed during biometric reverification.'); diff --git a/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts b/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts deleted file mode 100644 index fb8eaba5cae..00000000000 --- a/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -import { type NativeClientSnapshot, useNativeClientEvents } from '../useNativeClientEvents'; - -const mocks = vi.hoisted(() => { - return { - moduleAddListener: vi.fn(), - nativeModule: {} as unknown, - nativeListener: undefined as ((snapshot?: NativeClientSnapshot) => void) | undefined, - remove: vi.fn(), - }; -}); - -vi.mock('../../utils/native-module', () => { - return { - get ClerkExpoModule() { - return mocks.nativeModule; - }, - isNativeSupported: true, - }; -}); - -describe('useNativeClientEvents', () => { - beforeEach(() => { - mocks.nativeModule = { - addListener: mocks.moduleAddListener, - }; - mocks.nativeListener = undefined; - mocks.remove.mockReset(); - mocks.moduleAddListener.mockReset(); - mocks.moduleAddListener.mockImplementation((_eventName, listener) => { - mocks.nativeListener = listener; - return { remove: mocks.remove }; - }); - }); - - afterEach(() => { - cleanup(); - }); - - test('stores native client change payloads', async () => { - const { result, unmount } = renderHook(() => useNativeClientEvents()); - - expect(mocks.moduleAddListener).toHaveBeenCalledWith('clerkNativeClientChanged', expect.any(Function)); - - act(() => { - mocks.nativeListener?.({ - changed: { - client: false, - deviceToken: true, - }, - deviceToken: 'device-token', - sourceId: 'native-source', - }); - }); - - await waitFor(() => { - expect(result.current.nativeClientEvent?.deviceToken).toBe('device-token'); - expect(result.current.nativeClientEvent?.changed).toEqual({ - client: false, - deviceToken: true, - }); - expect(result.current.nativeClientEvent?.sourceId).toBe('native-source'); - }); - - unmount(); - }); - - test('subscribes only while native client events are enabled', () => { - const { rerender, unmount } = renderHook(({ enabled }) => useNativeClientEvents(enabled), { - initialProps: { enabled: false }, - }); - - expect(mocks.moduleAddListener).not.toHaveBeenCalled(); - - rerender({ enabled: true }); - expect(mocks.moduleAddListener).toHaveBeenCalledTimes(1); - - rerender({ enabled: false }); - expect(mocks.remove).toHaveBeenCalledTimes(1); - - unmount(); - }); - - test('does not subscribe modules without an Expo event emitter', () => { - mocks.nativeModule = { - configure: vi.fn(), - getClientToken: vi.fn(), - syncClientStateFromJs: vi.fn(), - }; - - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - - const { unmount } = renderHook(() => useNativeClientEvents()); - - expect(mocks.moduleAddListener).not.toHaveBeenCalled(); - expect(consoleError).not.toHaveBeenCalled(); - - consoleError.mockRestore(); - unmount(); - }); -}); diff --git a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts index 36c38445505..88b941f4a04 100644 --- a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts +++ b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts @@ -49,9 +49,10 @@ vi.mock('react-native', () => { vi.mock('../../specs/NativeClerkModule', () => { return { default: { - configure: vi.fn(), - getClientToken: vi.fn(), - syncClientStateFromJs: vi.fn(), + configureNative: vi.fn(), + getDeviceToken: vi.fn(), + setDeviceToken: vi.fn(), + refreshClient: vi.fn(), }, }; }); diff --git a/packages/expo/src/hooks/useNativeClientEvents.ts b/packages/expo/src/hooks/useNativeClientEvents.ts deleted file mode 100644 index a48a2ee316c..00000000000 --- a/packages/expo/src/hooks/useNativeClientEvents.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; - -const nativeClientChangedEvent = 'clerkNativeClientChanged'; - -export interface NativeClientSnapshot { - changed: { - client: boolean; - deviceToken: boolean; - }; - deviceToken: string | null; - sourceId?: string | null; -} - -/** - * Local marker for a native client event. - */ -export interface NativeClientEvent extends NativeClientSnapshot { - issuedAt: number; -} - -interface UseNativeClientEventsReturn { - nativeClientEvent: NativeClientEvent | null; -} - -type RefreshClientEventSubscription = { - remove: () => void; -}; - -type RefreshClientEventEmitter = { - addListener: ( - eventName: typeof nativeClientChangedEvent, - listener: (snapshot?: NativeClientSnapshot) => void, - ) => RefreshClientEventSubscription; -}; - -function getNativeClientEventEmitter(): RefreshClientEventEmitter | null { - if (ClerkExpo && typeof ClerkExpo.addListener === 'function') { - return ClerkExpo as RefreshClientEventEmitter; - } - - return null; -} - -function isNativeClientSnapshot(snapshot: NativeClientSnapshot | undefined): snapshot is NativeClientSnapshot { - return ( - typeof snapshot?.changed?.client === 'boolean' && - typeof snapshot.changed.deviceToken === 'boolean' && - (typeof snapshot.deviceToken === 'string' || snapshot.deviceToken === null) - ); -} - -/** - * Listens for native client events that should sync JS client state. - */ -export function useNativeClientEvents(enabled = true): UseNativeClientEventsReturn { - const [nativeClientEvent, setNativeClientEvent] = useState(null); - - useEffect(() => { - if (!enabled) { - setNativeClientEvent(null); - return; - } - - if (!isNativeSupported || !ClerkExpo) { - return; - } - - let subscription: { remove: () => void } | null = null; - - try { - const eventEmitter = getNativeClientEventEmitter(); - - if (!eventEmitter) { - return; - } - - subscription = eventEmitter.addListener(nativeClientChangedEvent, snapshot => { - if (!isNativeClientSnapshot(snapshot)) { - return; - } - - setNativeClientEvent({ issuedAt: Date.now(), ...snapshot }); - }); - } catch (error) { - if (__DEV__) { - console.error('[useNativeClientEvents] Failed to set up event listener:', error); - } - } - - return () => { - subscription?.remove(); - }; - }, [enabled]); - - return { - nativeClientEvent, - }; -} diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 8eb900d80f0..7c4fc4e72a5 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -2,19 +2,13 @@ import '../polyfills'; import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react'; import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal'; -import { useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import type { TokenCache } from '../cache/types'; +import { ClerkExpoModule } from '../utils/native-module'; import { isNative, isWeb } from '../utils/runtime'; import { maybeCompleteAuthSession } from './maybeCompleteAuthSession'; -import { - type DeviceTokenCacheListener, - NativeClientSync, - type NativeRefreshFromJsController, - useNativeClientBootstrap, - useNativeClientEventSync, - useSyncableTokenCache, -} from './nativeClientSync'; +import { createNativeClientSync, type SyncableClerk } from './nativeClientSync'; import { getClerkInstance } from './singleton'; import type { BuildClerkOptions } from './singleton/types'; @@ -77,20 +71,18 @@ export function ClerkProvider(props: ClerkProviderProps>(new Set()); - const suppressTokenCacheNotificationsRef = useRef(0); - const nativeRefreshFromJsControllerRef = useRef(null); - const syncableTokenCache = useSyncableTokenCache({ - suppressTokenCacheNotificationsRef, - tokenCache, - tokenCacheListenersRef, - }); + const nativeModule = isNative() && !__experimental_disableNativeClientSync ? ClerkExpoModule : null; + const tokenCacheRef = useRef(tokenCache); + tokenCacheRef.current = tokenCache; + const nativeClientSync = useMemo( + () => (nativeModule ? createNativeClientSync(nativeModule, pk, () => tokenCacheRef.current) : null), + [nativeModule, pk], + ); const clerkInstance = isNative() ? getClerkInstance({ publishableKey: pk, - tokenCache: syncableTokenCache, + tokenCache: nativeClientSync?.tokenCache ?? tokenCache, proxyUrl, domain, __experimental_passkeys, @@ -98,24 +90,13 @@ export function ClerkProvider(props: ClerkProviderProps + nativeClientSync && clerkInstance + ? nativeClientSync.attach(clerkInstance as unknown as SyncableClerk) + : undefined, + [nativeClientSync, clerkInstance], + ); // Needed for `useOAuth` / `useSSO` to work correctly on web — must stay synchronous during render // so the redirect URL is caught before children mount. Resolves to a no-op on native via the @@ -144,17 +125,6 @@ export function ClerkProvider(props: ClerkProviderProps - {nativeClientSyncEnabled && ( - - )} {children} ); diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx deleted file mode 100644 index ee2ff2a4d60..00000000000 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ /dev/null @@ -1,2397 +0,0 @@ -import { act, render, waitFor } from '@testing-library/react'; -import React, { type ReactNode, useEffect } from 'react'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import { CLERK_CLIENT_JWT_KEY } from '../../constants'; -import { ClerkProvider } from '../ClerkProvider'; -import { - __internal_resetNativeClientSyncCoordinator, - synchronizeNativeClientToJs, - waitForPendingJsToNativeSync, -} from '../nativeClientSyncCoordinator'; - -const mocks = vi.hoisted(() => { - return { - configure: vi.fn(), - getClientToken: vi.fn(), - nativeClientEvent: null as unknown, - syncClientStateFromJs: vi.fn(), - tokenCache: { - clearToken: vi.fn(), - getToken: vi.fn(), - saveToken: vi.fn(), - }, - clerkOptions: undefined as - | { - tokenCache?: { - clearToken: (key: string) => void | Promise; - getToken: (key: string) => Promise; - saveToken: (key: string, token: string) => Promise; - }; - } - | undefined, - clerkInstance: { - __internal_setActiveInProgress: false, - __internal_reloadInitialResources: vi.fn(), - addListener: vi.fn(), - addOnLoaded: vi.fn(), - client: undefined as unknown, - handleUnauthenticated: vi.fn(), - loaded: false, - off: vi.fn(), - on: vi.fn(), - session: undefined as unknown, - setActive: vi.fn(), - status: 'loading', - updateClient: vi.fn(), - }, - clerkListener: undefined as (() => void) | undefined, - clerkOnLoaded: undefined as (() => void) | undefined, - clerkStatusListener: undefined as ((status: string) => void) | undefined, - }; -}); - -vi.mock('../../polyfills', () => ({})); - -vi.mock('@clerk/react/internal', () => { - return { - InternalClerkProvider: ({ children }: { children: ReactNode }) => - React.createElement(React.Fragment, null, children), - }; -}); - -vi.mock('react-native', () => { - return { - NativeModules: { - BlobModule: {}, - }, - Platform: { - OS: 'ios', - constants: { - reactNativeVersion: { - major: 0, - minor: 81, - patch: 0, - }, - }, - }, - }; -}); - -vi.mock('expo-secure-store', () => { - return { - AFTER_FIRST_UNLOCK: 0, - deleteItemAsync: vi.fn(), - getItemAsync: vi.fn(), - setItemAsync: vi.fn(), - }; -}); - -vi.mock('../../hooks/useNativeClientEvents', () => { - return { - useNativeClientEvents: () => ({ - nativeClientEvent: mocks.nativeClientEvent, - }), - }; -}); - -vi.mock('../../specs/NativeClerkModule', () => { - return { - default: { - addListener: vi.fn(), - configure: mocks.configure, - getClientToken: mocks.getClientToken, - syncClientStateFromJs: mocks.syncClientStateFromJs, - }, - }; -}); - -vi.mock('../../utils/runtime', () => { - return { - isNative: () => true, - isWeb: () => false, - }; -}); - -vi.mock('../singleton', () => { - return { - getClerkInstance: (options?: { tokenCache?: typeof mocks.tokenCache }) => { - mocks.clerkOptions = options; - return mocks.clerkInstance; - }, - }; -}); - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void; - const promise = new Promise(innerResolve => { - resolve = innerResolve; - }); - return { promise, resolve }; -} - -function rejectableDeferred(): { promise: Promise; reject: (error: Error) => void } { - let reject!: (error: Error) => void; - const promise = new Promise((_resolve, innerReject) => { - reject = innerReject; - }); - return { promise, reject }; -} - -describe('ClerkProvider native client sync', () => { - beforeEach(() => { - __internal_resetNativeClientSyncCoordinator(); - vi.clearAllMocks(); - mocks.nativeClientEvent = null; - mocks.configure.mockResolvedValue(undefined); - mocks.getClientToken.mockResolvedValue(null); - mocks.syncClientStateFromJs.mockResolvedValue(undefined); - mocks.tokenCache.getToken.mockResolvedValue(null); - mocks.tokenCache.saveToken.mockResolvedValue(undefined); - mocks.tokenCache.clearToken.mockResolvedValue(undefined); - mocks.clerkOptions = undefined; - mocks.clerkInstance.__internal_setActiveInProgress = false; - mocks.clerkInstance.__internal_reloadInitialResources.mockResolvedValue(undefined); - mocks.clerkInstance.addOnLoaded = vi.fn(); - mocks.clerkInstance.client = undefined; - mocks.clerkInstance.handleUnauthenticated = vi.fn().mockResolvedValue(undefined); - mocks.clerkInstance.loaded = true; - mocks.clerkInstance.off.mockReset(); - mocks.clerkInstance.on.mockReset(); - mocks.clerkInstance.session = undefined; - mocks.clerkInstance.setActive.mockResolvedValue(undefined); - mocks.clerkInstance.status = 'ready'; - mocks.clerkInstance.updateClient = vi.fn(); - mocks.clerkInstance.updateClient.mockImplementation(client => { - mocks.clerkInstance.client = client; - const currentSession = mocks.clerkInstance.session as { id?: string } | null | undefined; - mocks.clerkInstance.session = currentSession - ? client.signedInSessions.find((session: { id: string }) => session.id === currentSession.id) || null - : currentSession; - }); - mocks.clerkListener = undefined; - mocks.clerkOnLoaded = undefined; - mocks.clerkStatusListener = undefined; - mocks.clerkInstance.addOnLoaded.mockImplementation(listener => { - mocks.clerkOnLoaded = listener; - }); - mocks.clerkInstance.addListener.mockImplementation(listener => { - mocks.clerkListener = listener; - return vi.fn(); - }); - mocks.clerkInstance.on.mockImplementation((event, listener) => { - if (event === 'status') { - mocks.clerkStatusListener = listener; - } - }); - }); - - test('does not start native client synchronization when disabled', async () => { - const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - - render( - , - ); - - await act(async () => { - await Promise.resolve(); - }); - - expect(mocks.configure).not.toHaveBeenCalled(); - expect(mocks.getClientToken).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.addListener).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.handleUnauthenticated).toBe(originalHandleUnauthenticated); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - }); - - test('configures native once with the cached device token during StrictMode bootstrap', async () => { - const configure = deferred(); - mocks.tokenCache.getToken.mockResolvedValue('client-token'); - mocks.getClientToken.mockResolvedValue('client-token'); - mocks.configure.mockReturnValue(configure.promise); - - render( - - - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); - }); - expect(mocks.configure).toHaveBeenCalledTimes(1); - let didFinishWaiting = false; - const waiting = waitForPendingJsToNativeSync().then(() => { - didFinishWaiting = true; - }); - await Promise.resolve(); - expect(didFinishWaiting).toBe(false); - - act(() => { - configure.resolve(); - }); - await waiting; - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - }); - - test('registers native bootstrap before child effects can await synchronization', async () => { - const configure = deferred(); - mocks.configure.mockReturnValue(configure.promise); - let didFinishWaiting = false; - - function Child() { - useEffect(() => { - void waitForPendingJsToNativeSync().then(() => { - didFinishWaiting = true; - }); - }, []); - return null; - } - - render( - - - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - await Promise.resolve(); - expect(didFinishWaiting).toBe(false); - - act(() => { - configure.resolve(); - }); - await waitFor(() => { - expect(didFinishWaiting).toBe(true); - }); - }); - - test('syncs the native device token to JS after Clerk loads during bootstrap', async () => { - mocks.clerkInstance.loaded = false; - mocks.clerkInstance.status = 'loading'; - mocks.getClientToken.mockResolvedValue('native-client-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.on).toHaveBeenCalledWith('status', expect.any(Function)); - }); - expect(mocks.configure).not.toHaveBeenCalled(); - expect(mocks.getClientToken).not.toHaveBeenCalled(); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - act(() => { - mocks.clerkListener?.(); - }); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - - await act(async () => { - mocks.clerkInstance.loaded = true; - mocks.clerkInstance.status = 'ready'; - mocks.clerkStatusListener?.('ready'); - }); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - }); - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); - expect(mocks.clerkInstance.off).toHaveBeenCalledWith('status', expect.any(Function)); - }); - - test('syncs a JS token rotated during bootstrap to native exactly once', async () => { - const configure = deferred(); - mocks.configure.mockReturnValue(configure.promise); - mocks.tokenCache.getToken.mockResolvedValueOnce('cached-client-token').mockResolvedValue('rotated-client-token'); - mocks.getClientToken.mockResolvedValue('native-client-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'cached-client-token'); - }); - expect(mocks.configure).toHaveBeenCalledTimes(1); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - - act(() => { - configure.resolve(); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith( - 'rotated-client-token', - 'clerk-expo-js-sync-bootstrap', - true, - true, - ); - }); - expect(mocks.syncClientStateFromJs).toHaveBeenCalledTimes(1); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - }); - - test('flushes one JS client change that occurs after JS loads but before native is ready', async () => { - const configure = deferred(); - mocks.configure.mockReturnValue(configure.promise); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledTimes(1); - expect(mocks.clerkInstance.addListener).toHaveBeenCalled(); - }); - - act(() => { - mocks.clerkListener?.(); - }); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - - act(() => { - configure.resolve(); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false); - }); - expect(mocks.syncClientStateFromJs).toHaveBeenCalledTimes(1); - }); - - test('preserves native configure failures and keeps synchronization disabled', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const configureError = new Error('native refresh failed'); - const firstConfigure = rejectableDeferred(); - mocks.configure.mockReturnValueOnce(firstConfigure.promise).mockRejectedValue(configureError); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledTimes(1); - expect(mocks.clerkInstance.addListener).toHaveBeenCalled(); - }); - - act(() => { - mocks.clerkListener?.(); - firstConfigure.reject(configureError); - }); - await expect(waitForPendingJsToNativeSync()).rejects.toBe(configureError); - expect(mocks.configure).toHaveBeenCalledTimes(2); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { client: true, deviceToken: true }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - - consoleError.mockRestore(); - }); - - test('does not wait for an obsolete native bootstrap after switching publishable keys', async () => { - const obsoleteConfigure = deferred(); - mocks.configure.mockReturnValueOnce(obsoleteConfigure.promise).mockResolvedValueOnce(undefined); - - const { rerender } = render( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null)); - - rerender( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_456', null)); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - expect(mocks.configure).toHaveBeenCalledTimes(2); - }); - - test('does not wait for an active native refresh after switching publishable keys', async () => { - const obsoleteRefresh = rejectableDeferred(); - const obsoleteRefreshError = new Error('obsolete native refresh failed'); - - const { rerender } = render( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null)); - await waitForPendingJsToNativeSync(); - - mocks.syncClientStateFromJs.mockReturnValueOnce(obsoleteRefresh.promise); - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'obsolete-client-token'); - }); - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith( - 'obsolete-client-token', - expect.any(String), - false, - true, - ); - }); - - rerender( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_456', null)); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - - obsoleteRefresh.reject(obsoleteRefreshError); - await Promise.resolve(); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - }); - - test('retries a transient native configure failure', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const configureError = new Error('transient native refresh failure'); - mocks.configure.mockRejectedValueOnce(configureError); - - const { rerender } = render( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(2)); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { client: true, deviceToken: true }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - await waitFor(() => - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'), - ); - consoleError.mockRestore(); - }); - - test('disables synchronization when switching publishable keys fails to configure native', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const configureError = new Error('native key switch failed'); - - const { rerender } = render( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(1)); - await waitForPendingJsToNativeSync(); - mocks.configure.mockRejectedValue(configureError); - mocks.tokenCache.saveToken.mockClear(); - mocks.syncClientStateFromJs.mockClear(); - - rerender( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(2)); - await expect(waitForPendingJsToNativeSync()).rejects.toBe(configureError); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { client: true, deviceToken: true }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - expect(mocks.configure).toHaveBeenLastCalledWith('pk_test_456', null); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - - consoleError.mockRestore(); - }); - - test('keeps native recovery authoritative when JS creates a client from an empty cache', async () => { - mocks.tokenCache.getToken.mockResolvedValueOnce(null).mockResolvedValue('anonymous-js-token'); - mocks.getClientToken.mockResolvedValue('native-client-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - }); - expect(mocks.configure).toHaveBeenCalledTimes(1); - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalledTimes(1); - }); - - test('does not notify native when the token cache writes the current token again', async () => { - mocks.tokenCache.getToken.mockResolvedValue('client-token'); - mocks.getClientToken.mockResolvedValue('client-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.addListener).toHaveBeenCalled(); - }); - - mocks.syncClientStateFromJs.mockClear(); - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - }); - - test('syncs JS token cache changes when ClerkProvider uses the default token cache', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - - render(); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - mocks.syncClientStateFromJs.mockClear(); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); - }); - }); - - test('reloads JS resources after native emits a device token change', async () => { - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: false, - deviceToken: true, - }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - }); - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); - }); - - test('reloads JS resources after native clears the device token', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - mocks.tokenCache.saveToken.mockClear(); - mocks.tokenCache.clearToken.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: false, - deviceToken: true, - }, - deviceToken: null, - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); - }); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, expect.anything()); - expect(mocks.tokenCache.clearToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY); - }); - - test('reloads JS resources after a native client-only change without rewriting the token cache', async () => { - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - mocks.tokenCache.saveToken.mockClear(); - mocks.tokenCache.clearToken.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: false, - }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); - }); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, expect.anything()); - expect(mocks.tokenCache.clearToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY); - }); - - test('does not bounce a JS client listener event while applying a native client change', async () => { - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.syncClientStateFromJs.mockClear(); - mocks.clerkInstance.__internal_reloadInitialResources.mockImplementation(() => { - mocks.clerkListener?.(); - }); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); - }); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - }); - - test('serializes native token writes while keeping cache notifications suppressed', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - - const firstSave = deferred(); - const secondSave = deferred(); - mocks.tokenCache.saveToken - .mockImplementationOnce(() => firstSave.promise) - .mockImplementationOnce(() => secondSave.promise); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - mocks.syncClientStateFromJs.mockClear(); - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: false, - deviceToken: true, - }, - deviceToken: 'native-client-token-1', - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token-1'); - }); - - mocks.nativeClientEvent = { - issuedAt: 2, - changed: { - client: false, - deviceToken: true, - }, - deviceToken: 'native-client-token-2', - }; - rerender( - , - ); - - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token-2'); - - await act(async () => { - firstSave.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token-2'); - }); - - await act(async () => { - secondSave.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); - - await waitFor(() => { - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalledTimes(2); - }); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - }); - - test('emits the refreshed JS client after a native client update keeps the active session', async () => { - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1', lastName: 'Before' }, - }; - const updatedActiveSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1', lastName: 'After' }, - }; - const refreshedClient = { - signedInSessions: [updatedActiveSession], - lastActiveSessionId: 'session_1', - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - mocks.clerkInstance.client = { - signedInSessions: [activeSession], - lastActiveSessionId: 'session_1', - fetch: vi.fn().mockResolvedValue(refreshedClient), - }; - mocks.clerkInstance.session = activeSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: false, - }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - await waitFor(() => { - expect(originalUpdateClient).toHaveBeenCalledWith(refreshedClient); - }); - expect(originalUpdateClient).not.toHaveBeenCalledWith(refreshedClient, { - __internal_dangerouslySkipEmit: true, - }); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled(); - }); - - test('sets the refreshed native last active session without emitting a stale signed-out JS state', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const remainingSession = { - id: 'session_2', - status: 'active', - user: { id: 'user_2' }, - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - fetch: vi.fn().mockResolvedValue({ - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }), - }; - mocks.clerkInstance.session = removedSession; - mocks.clerkInstance.setActive.mockImplementation(({ session }) => { - mocks.clerkInstance.session = session; - return Promise.resolve(); - }); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.clerkInstance.setActive.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: remainingSession }); - }); - expect(originalUpdateClient).toHaveBeenCalledWith( - { - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }, - { __internal_dangerouslySkipEmit: true }, - ); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - }); - - test('does not explicitly sign JS out when a native client change leaves no signed-in sessions', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - fetch: vi.fn().mockResolvedValue({ - signedInSessions: [], - lastActiveSessionId: null, - }), - }; - mocks.clerkInstance.session = removedSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.clerkInstance.setActive.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: null, - }; - rerender( - , - ); - - await waitFor(() => { - expect(originalUpdateClient).toHaveBeenCalledWith({ - signedInSessions: [], - lastActiveSessionId: null, - }); - }); - expect(originalUpdateClient).not.toHaveBeenCalledWith( - { - signedInSessions: [], - lastActiveSessionId: null, - }, - { __internal_dangerouslySkipEmit: true }, - ); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled(); - }); - - test('rejects a foreign sessionless client when refreshing mutates the JS client in place', async () => { - const signedInSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - const client = { - id: 'client_1', - sessions: [signedInSession], - lastActiveSessionId: 'session_1' as string | null, - get signedInSessions() { - return this.sessions; - }, - __internal_toSnapshot() { - return { - id: this.id, - sessions: this.sessions, - last_active_session_id: this.lastActiveSessionId, - }; - }, - fromJSON(snapshot: { id: string; sessions: (typeof signedInSession)[]; last_active_session_id: string | null }) { - this.id = snapshot.id; - this.sessions = snapshot.sessions; - this.lastActiveSessionId = snapshot.last_active_session_id; - return this; - }, - fetch() { - this.id = 'client_2'; - this.sessions = []; - this.lastActiveSessionId = null; - return Promise.resolve(this); - }, - }; - const restoreClient = vi.spyOn(client, 'fromJSON'); - - mocks.clerkInstance.client = client; - mocks.clerkInstance.session = signedInSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - originalUpdateClient.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: null, - }; - rerender( - , - ); - - await waitFor(() => { - expect(restoreClient).toHaveBeenCalled(); - }); - - expect(client.id).toBe('client_1'); - expect(client.sessions).toEqual([signedInSession]); - expect(client.signedInSessions).toEqual([signedInSession]); - expect(client.lastActiveSessionId).toBe('session_1'); - expect(originalUpdateClient).toHaveBeenCalledWith(client); - expect(mocks.clerkInstance.session).toBe(signedInSession); - }); - - test('keeps the remaining JS session when the old active session becomes unauthenticated', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const remainingSession = { - id: 'session_2', - status: 'active', - user: { id: 'user_2' }, - }; - const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - fetch: vi.fn().mockResolvedValue({ - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }), - }; - mocks.clerkInstance.session = removedSession; - mocks.clerkInstance.setActive.mockImplementation(({ session }) => { - mocks.clerkInstance.session = session; - return Promise.resolve(); - }); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); - }); - - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - }); - - expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); - expect(originalUpdateClient).toHaveBeenCalledWith( - { - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }, - { __internal_dangerouslySkipEmit: true }, - ); - expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: remainingSession }); - }); - - test('treats client payloads that remove the active session as a session switch when another session remains', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const remainingSession = { - id: 'session_2', - status: 'active', - user: { id: 'user_2' }, - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - }; - mocks.clerkInstance.session = removedSession; - mocks.clerkInstance.setActive.mockImplementation(({ session }) => { - mocks.clerkInstance.session = session; - return Promise.resolve(); - }); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient); - }); - - originalUpdateClient.mockClear(); - - act(() => { - mocks.clerkInstance.updateClient({ - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }); - }); - - await waitFor(() => { - expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: remainingSession }); - }); - expect(originalUpdateClient).toHaveBeenCalledWith( - { - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }, - { __internal_dangerouslySkipEmit: true }, - ); - expect(originalUpdateClient).not.toHaveBeenCalledWith({ - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }); - }); - - test('does not start fallback activation during an explicit session transition', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const replacementSession = { - id: 'session_2', - status: 'active', - user: { id: 'user_2' }, - }; - const replacementClient = { - signedInSessions: [replacementSession], - lastActiveSessionId: 'session_2', - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - }; - mocks.clerkInstance.session = removedSession; - - render( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient); - }); - - originalUpdateClient.mockClear(); - mocks.clerkInstance.setActive.mockClear(); - mocks.clerkInstance.__internal_setActiveInProgress = true; - - act(() => { - mocks.clerkInstance.updateClient(replacementClient); - }); - - expect(originalUpdateClient).toHaveBeenCalledOnce(); - expect(originalUpdateClient).toHaveBeenCalledWith(replacementClient, { - __internal_dangerouslySkipEmit: true, - }); - expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled(); - }); - - test('keeps follow-up client updates suppressed while reconciling a removed active session', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const remainingSession = { - id: 'session_2', - status: 'active', - user: { id: 'user_2' }, - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - let resolveSetActive: (() => void) | undefined; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession, remainingSession], - lastActiveSessionId: 'session_1', - }; - mocks.clerkInstance.session = removedSession; - mocks.clerkInstance.setActive.mockImplementation(({ session }) => { - return new Promise(resolve => { - resolveSetActive = () => { - mocks.clerkInstance.session = session; - resolve(); - }; - }); - }); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient); - }); - - originalUpdateClient.mockClear(); - - act(() => { - mocks.clerkInstance.updateClient( - { - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }, - { __internal_dangerouslySkipEmit: true }, - ); - mocks.clerkInstance.updateClient({ - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }); - }); - - expect(originalUpdateClient).toHaveBeenNthCalledWith( - 1, - { - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }, - { __internal_dangerouslySkipEmit: true }, - ); - expect(originalUpdateClient).toHaveBeenNthCalledWith( - 2, - { - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }, - { __internal_dangerouslySkipEmit: true }, - ); - expect(originalUpdateClient).not.toHaveBeenCalledWith({ - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }); - - await act(async () => { - resolveSetActive?.(); - }); - - expect(mocks.clerkInstance.setActive).toHaveBeenCalledTimes(1); - expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: remainingSession }); - }); - - test('does not fall back to JS sign-out when stale unauthenticated recovery still has a native device token', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - 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 () => { - if (reentersUnauthenticated) { - await mocks.clerkInstance.handleUnauthenticated(); - } - throw new Error('stale session 401'); - }), - }; - mocks.clerkInstance.session = removedSession; - mocks.getClientToken.mockResolvedValue('native-client-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); - }); - - reentersUnauthenticated = true; - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - }); - - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.setActive).not.toHaveBeenCalledWith({ session: null }); - }); - - test('falls back to JS unauthenticated handling when native token recovery has no signed-in sessions', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - fetch: vi.fn().mockRejectedValue(new Error('stale session 401')), - }; - mocks.clerkInstance.session = removedSession; - mocks.getClientToken.mockResolvedValue('native-client-token'); - mocks.clerkInstance.__internal_reloadInitialResources.mockImplementation(() => { - mocks.clerkInstance.client = { - signedInSessions: [], - lastActiveSessionId: null, - }; - mocks.clerkInstance.session = null; - }); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); - }); - - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - }); - - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - 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); - }); - - // Bootstrap's suppressed device token write would swallow the rotation below. - await act(async () => { - await waitForPendingJsToNativeSync(); - }); - - // 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('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); - }); - - // Bootstrap fetches the client too, and that fetch fails here, so drain its retries first. - await act(async () => { - await expect(waitForPendingJsToNativeSync()).rejects.toThrow('stale session 401'); - }); - - 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); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - mocks.syncClientStateFromJs.mockClear(); - mocks.tokenCache.getToken.mockResolvedValue('client-token'); - act(() => { - mocks.clerkListener?.(); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false); - }); - }); - - test('retries failed native state while processing a queued sync', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - let rejectFirstSync: ((error: Error) => void) | undefined; - mocks.syncClientStateFromJs.mockImplementationOnce(() => { - return new Promise((_resolve, reject) => { - rejectFirstSync = reject; - }); - }); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - act(() => { - mocks.clerkListener?.(); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false); - }); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - rejectFirstSync?.(new Error('native sync failed')); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), true, true); - }); - }); - - test('keeps a pending native client refresh while a token sync is in flight', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - let resolveFirstSync: (() => void) | undefined; - mocks.syncClientStateFromJs.mockImplementationOnce(() => { - return new Promise(resolve => { - resolveFirstSync = resolve; - }); - }); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); - }); - - act(() => { - mocks.clerkListener?.(); - }); - - await act(async () => { - resolveFirstSync?.(); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false); - }); - }); - - test('refreshes native with the saved token after the JS token cache changes', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - mocks.syncClientStateFromJs.mockClear(); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); - }); - }); - - test('tracks an in-flight device-token sync until native reconciliation completes', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - const nativeSync = deferred(); - mocks.syncClientStateFromJs.mockReturnValueOnce(nativeSync.promise); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); - }); - - let didFinishWaiting = false; - const waiting = waitForPendingJsToNativeSync().then(() => { - didFinishWaiting = true; - }); - await Promise.resolve(); - expect(didFinishWaiting).toBe(false); - - nativeSync.resolve(); - await waiting; - expect(didFinishWaiting).toBe(true); - }); - - test('preserves a failed native refresh until a later refresh succeeds', async () => { - const error = new Error('native refresh failed'); - const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - mocks.tokenCache.getToken.mockResolvedValue(null); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - mocks.syncClientStateFromJs.mockRejectedValueOnce(error); - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'failed-client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('failed-client-token', expect.any(String), false, true); - }); - await expect(waitForPendingJsToNativeSync()).rejects.toBe(error); - await expect(waitForPendingJsToNativeSync()).rejects.toBe(error); - - mocks.syncClientStateFromJs.mockResolvedValueOnce(undefined); - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'recovered-client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith( - 'recovered-client-token', - expect.any(String), - false, - true, - ); - }); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - consoleWarn.mockRestore(); - }); - - test('ignores a canceled native refresh that later rejects', async () => { - const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - mocks.tokenCache.getToken.mockResolvedValue(null); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - await waitForPendingJsToNativeSync(); - - const staleRefresh = rejectableDeferred(); - const staleError = new Error('canceled native refresh failed'); - mocks.syncClientStateFromJs.mockReturnValueOnce(staleRefresh.promise); - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'stale-client-token'); - }); - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('stale-client-token', expect.any(String), false, true); - }); - - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { client: true, deviceToken: true }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - await waitFor(() => { - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); - }); - - staleRefresh.reject(staleError); - await Promise.resolve(); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - consoleWarn.mockRestore(); - }); - - test('awaits JS session activation during explicit native-to-JS synchronization', async () => { - const activeSession = { - id: 'sess_native', - status: 'active', - user: { id: 'user_native' }, - }; - const refreshedClient = { - id: 'client_1', - signIn: { - id: 'sia_native', - status: 'complete', - createdSessionId: activeSession.id, - }, - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - }; - const fetchClient = vi.fn().mockResolvedValue(refreshedClient); - mocks.tokenCache.getToken.mockResolvedValue('native-client-token'); - mocks.getClientToken.mockResolvedValue('native-client-token'); - mocks.clerkInstance.client = { - id: 'client_1', - signIn: { id: '', status: null, createdSessionId: null }, - signedInSessions: [], - lastActiveSessionId: null, - fetch: fetchClient, - }; - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'native-client-token'); - }); - fetchClient.mockClear(); - - const activation = deferred(); - mocks.clerkInstance.setActive.mockReturnValueOnce(activation.promise); - let didFinishSync = false; - const sync = synchronizeNativeClientToJs().then(() => { - didFinishSync = true; - }); - - await waitFor(() => { - expect(fetchClient).toHaveBeenCalledTimes(1); - expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: activeSession }); - }); - expect(didFinishSync).toBe(false); - - activation.resolve(); - await sync; - expect(didFinishSync).toBe(true); - }); - - test('ignores native client events that echo a JS-originated sync', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - mocks.syncClientStateFromJs.mockClear(); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); - }); - - const sourceId = mocks.syncClientStateFromJs.mock.calls[0]?.[1]; - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: false, - deviceToken: true, - }, - deviceToken: 'client-token', - sourceId, - }; - rerender( - , - ); - - await act(async () => {}); - - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - }); - - test('refreshes native from the server after the JS token cache is cleared', async () => { - mocks.tokenCache.getToken.mockResolvedValue('client-token'); - mocks.getClientToken.mockResolvedValue('client-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.addListener).toHaveBeenCalled(); - }); - - mocks.syncClientStateFromJs.mockClear(); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.clearToken?.(CLERK_CLIENT_JWT_KEY); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), false, true); - }); - }); - - test('rejects a foreign session-less native client and restores the signed-in JS token', async () => { - const jsDeviceToken = 'js-device-token'; - const nativeDeviceToken = 'native-device-token'; - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const foreignClient = { - id: 'client_foreign', - signedInSessions: [], - lastActiveSessionId: null, - }; - const updateClient = mocks.clerkInstance.updateClient; - - mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); - mocks.getClientToken.mockResolvedValue(jsDeviceToken); - mocks.clerkInstance.client = { - id: 'client_js', - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - fetch: vi.fn().mockResolvedValue(foreignClient), - }; - mocks.clerkInstance.session = activeSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); - }); - - mocks.syncClientStateFromJs.mockClear(); - mocks.tokenCache.saveToken.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: nativeDeviceToken, - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); - }); - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken); - expect(updateClient).not.toHaveBeenCalledWith(foreignClient); - expect(mocks.clerkInstance.session).toBe(activeSession); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); - }); - }); - - test('restores the signed-in JS token when native client verification fails', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const jsDeviceToken = 'js-device-token'; - const nativeDeviceToken = 'native-device-token'; - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - - mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); - mocks.getClientToken.mockResolvedValue(jsDeviceToken); - mocks.clerkInstance.client = { - id: 'client_js', - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - fetch: vi.fn().mockRejectedValue(new Error('verification failed')), - }; - mocks.clerkInstance.session = activeSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); - }); - - mocks.syncClientStateFromJs.mockClear(); - mocks.tokenCache.saveToken.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: nativeDeviceToken, - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); - }); - expect(mocks.clerkInstance.session).toBe(activeSession); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); - }); - - consoleError.mockRestore(); - }); - - test('does not replace a signed-in JS token when the native client cannot be verified', async () => { - const jsDeviceToken = 'js-device-token'; - const nativeDeviceToken = 'native-device-token'; - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - - mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); - mocks.getClientToken.mockResolvedValue(jsDeviceToken); - mocks.clerkInstance.client = { - id: 'client_js', - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - }; - mocks.clerkInstance.session = activeSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); - }); - - mocks.syncClientStateFromJs.mockClear(); - mocks.tokenCache.saveToken.mockClear(); - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: nativeDeviceToken, - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); - }); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.session).toBe(activeSession); - }); - - test('applies a session-less native response when it belongs to the current JS client', async () => { - const jsDeviceToken = 'js-device-token'; - const nativeDeviceToken = 'native-device-token'; - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const signedOutClient = { - id: 'client_shared', - signedInSessions: [], - lastActiveSessionId: null, - }; - const updateClient = mocks.clerkInstance.updateClient; - - mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); - mocks.getClientToken.mockResolvedValue(jsDeviceToken); - mocks.clerkInstance.client = { - id: 'client_shared', - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - fetch: vi.fn().mockResolvedValue(signedOutClient), - }; - mocks.clerkInstance.session = activeSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); - }); - - mocks.tokenCache.saveToken.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: nativeDeviceToken, - }; - rerender( - , - ); - - await waitFor(() => { - expect(updateClient).toHaveBeenCalledWith(signedOutClient); - }); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); - expect(mocks.clerkInstance.session).toBeNull(); - }); - - test('rejects a foreign session-less native client during unauthenticated recovery', async () => { - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const foreignClient = { - id: 'client_foreign', - signedInSessions: [], - lastActiveSessionId: null, - }; - const updateClient = mocks.clerkInstance.updateClient; - const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - - mocks.tokenCache.getToken.mockResolvedValue('js-device-token'); - mocks.getClientToken.mockResolvedValue('js-device-token'); - mocks.clerkInstance.client = { - id: 'client_js', - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - fetch: vi.fn().mockResolvedValue(foreignClient), - }; - mocks.clerkInstance.session = activeSession; - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token'); - }); - await waitFor(() => { - expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); - }); - - mocks.getClientToken.mockResolvedValue('ghost-device-token'); - mocks.tokenCache.saveToken.mockClear(); - mocks.syncClientStateFromJs.mockClear(); - - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - }); - - expect(updateClient).not.toHaveBeenCalledWith(foreignClient); - expect(mocks.clerkInstance.session).toBe(activeSession); - 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 () => { - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const fetchClient = vi.fn().mockResolvedValue({ - id: 'client_foreign', - signedInSessions: [], - lastActiveSessionId: null, - }); - - mocks.tokenCache.getToken.mockResolvedValue('js-device-token'); - mocks.getClientToken.mockResolvedValue('js-device-token'); - mocks.clerkInstance.client = { - id: 'client_js', - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - fetch: fetchClient, - }; - mocks.clerkInstance.session = activeSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token'); - }); - - mocks.tokenCache.getToken.mockImplementation(() => new Promise(() => {})); - mocks.tokenCache.saveToken.mockClear(); - mocks.tokenCache.clearToken.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: 'native-device-token', - }; - rerender( - , - ); - - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 1_200)); - }); - - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-device-token'); - expect(mocks.tokenCache.clearToken).not.toHaveBeenCalled(); - expect(fetchClient).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.session).toBe(activeSession); - }); -}); diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/currentEngineAdapter.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/currentEngineAdapter.tsx deleted file mode 100644 index 5671ed55ce7..00000000000 --- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/currentEngineAdapter.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { act, render, waitFor } from '@testing-library/react'; -import { vi } from 'vitest'; - -import { CLERK_CLIENT_JWT_KEY } from '../../../constants'; -import type { NativeClientSnapshot } from '../../../hooks/useNativeClientEvents'; -import type { ClerkExpoNativeModule } from '../../../utils/native-module'; -import { FakePersistentTokenCache, setActiveEnvironment } from './environment'; -import { FakeClerk } from './fakeClerk'; -import { FakeNativeClerk } from './fakeNativeClerk'; -import { FakeClerkServer } from './fakeServer'; -import type { NativeSyncHarness, RenderWithNativeSync } from './harness'; - -const nativeClientChangedEvent = 'clerkNativeClientChanged'; - -function serializeClient(native: FakeNativeClerk): string { - return JSON.stringify(native.client); -} - -/** - * Today's `ClerkExpo` native module contract, modeled on the iOS bridge: `configure` adopts a bearer - * token, `syncClientStateFromJs` adopts/refreshes and echoes a change tagged with the JS `sourceId`, and - * native-originated changes are emitted as `clerkNativeClientChanged` snapshots. - */ -function createCurrentNativeModule(native: FakeNativeClerk, calls: string[]): ClerkExpoNativeModule { - const listeners = new Set<(snapshot: NativeClientSnapshot) => void>(); - - native.onChange((change, origin) => { - const snapshot: NativeClientSnapshot = { - changed: { client: change.client, deviceToken: change.deviceToken }, - deviceToken: native.token, - ...(origin.kind === 'js' && origin.tag ? { sourceId: origin.tag } : {}), - }; - for (const listener of [...listeners]) { - listener(snapshot); - } - }); - - return { - addListener: (eventName: string, listener?: (...args: unknown[]) => void) => { - calls.push('addListener'); - if (eventName !== nativeClientChangedEvent || !listener) { - return { remove: () => undefined }; - } - const typedListener = listener as (snapshot: NativeClientSnapshot) => void; - listeners.add(typedListener); - return { remove: () => listeners.delete(typedListener) }; - }, - configure: async (_publishableKey: string, bearerToken: string | null) => { - calls.push('configure'); - await native.waitForStartup(); - if (bearerToken && (bearerToken !== native.token || !native.client)) { - await native.adoptToken(bearerToken); - } else { - await native.refreshClient(); - } - native.isConfigured = true; - }, - getClientToken: () => { - calls.push('getClientToken'); - return Promise.resolve(native.token); - }, - syncClientStateFromJs: async ( - deviceToken: string | null, - sourceId: string | null, - didChangeClient: boolean, - didChangeDeviceToken: boolean, - ) => { - calls.push('syncClientStateFromJs'); - if (!native.isConfigured) { - return; - } - const previousToken = native.token; - const previousClient = serializeClient(native); - const changeSincePrevious = () => ({ - client: serializeClient(native) !== previousClient, - deviceToken: native.token !== previousToken, - }); - try { - if (didChangeDeviceToken && deviceToken && deviceToken !== native.token) { - await native.adoptToken(deviceToken); - } else if (didChangeClient || didChangeDeviceToken) { - await native.refreshClient(); - } - } catch (error) { - const change = changeSincePrevious(); - if (change.client || change.deviceToken) { - native.emitChange(change, { kind: 'native' }); - } - throw error; - } - native.emitChange(changeSincePrevious(), { kind: 'js', tag: sourceId }); - }, - }; -} - -async function flushMacrotasks(count: number): Promise { - for (let i = 0; i < count; i++) { - await new Promise(resolve => setTimeout(resolve, 0)); - } -} - -export const renderWithNativeSync: RenderWithNativeSync = async (scenario = {}) => { - const server = scenario.server ?? new FakeClerkServer(); - const native = new FakeNativeClerk(server, scenario.nativeDeviceToken ?? null); - const js = new FakeClerk(server); - const persistentTokenCache = - scenario.persistentTokenCache || scenario.jsDeviceToken !== undefined - ? new FakePersistentTokenCache(scenario.jsDeviceToken ? { [CLERK_CLIENT_JWT_KEY]: scenario.jsDeviceToken } : {}) - : null; - const releaseNativeStartup = scenario.delayNativeStartup ? native.delayStartup() : () => undefined; - const nativeModuleCalls: string[] = []; - - setActiveEnvironment({ - js, - nativeModule: scenario.nativeModule === 'absent' ? null : createCurrentNativeModule(native, nativeModuleCalls), - }); - - // Fresh engine modules per scenario: the native module is resolved at import time and the - // coordinator and default in-memory token cache are module singletons. - vi.resetModules(); - const { ClerkProvider } = await import('../../ClerkProvider'); - const { synchronizeNativeClientToJs, waitForPendingJsToNativeSync } = - await import('../../nativeClientSyncCoordinator'); - - const view = render( - , - ); - - const settle = async () => { - for (let round = 0; round < 8; round++) { - await act(async () => { - await flushMacrotasks(10); - }); - } - }; - - await waitFor(() => { - if (!js.loaded) { - throw new Error('Clerk has not loaded yet'); - } - }); - await settle(); - - const harness: NativeSyncHarness = { - server, - native, - js, - persistentTokenCache, - jsDeviceToken: () => js.currentDeviceToken(), - nativeModuleCalls, - run: async action => { - let result!: Awaited>; - await act(async () => { - result = await action(); - }); - return result; - }, - settle, - awaitJsToNativeSync: () => waitForPendingJsToNativeSync(), - pullNativeToJs: () => synchronizeNativeClientToJs(), - releaseNativeStartup, - unmount: () => view.unmount(), - }; - return harness; -}; diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts index 9af865fa942..8358f2e7d43 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts @@ -1,11 +1,13 @@ import type { TokenCache } from '../../../cache/types'; import { CLERK_CLIENT_JWT_KEY } from '../../../constants'; +import type { ClientTokenCache } from '../../nativeClientSync'; import type { FakeClerkServer, FakeServerClientJSON, FapiResponse } from './fakeServer'; export type FakeSessionResource = { id: string; status: 'active'; - user: { id: string; profileVersion: number }; + updatedAt: Date; + user: { id: string; profileVersion: number; updatedAt: Date }; }; export type JsEmission = { @@ -17,7 +19,13 @@ type ListenerCallback = (resources: { client: FakeClientResource; session: FakeS type StatusListener = (status: string) => void; function toSessionResource(session: FakeServerClientJSON['sessions'][number]): FakeSessionResource { - return { id: session.id, status: 'active', user: { id: session.userId, profileVersion: session.profileVersion } }; + const updatedAt = new Date(session.profileVersion); + return { + id: session.id, + status: 'active', + updatedAt, + user: { id: session.userId, profileVersion: session.profileVersion, updatedAt }, + }; } /** @@ -63,7 +71,8 @@ export class FakeClientResource { /** * Stand-in for the clerk-js instance returned by `getClerkInstance`, with the same surface the existing * ClerkProvider tests mock. It talks to the fake Frontend API through the token cache it was built with, - * mirroring the `__internal_onBeforeRequest` / `__internal_onAfterResponse` hooks in `createClerkInstance`. + * mirroring the `__internal_onBeforeRequest` / `__internal_onAfterResponse` hooks in `createClerkInstance`, + * including the compare-and-set write against the token the request used. */ export class FakeClerk { loaded = false; @@ -73,7 +82,7 @@ export class FakeClerk { __internal_setActiveInProgress = false; /** Every state emitted to Clerk listeners (what `useAuth` consumers observe). */ readonly emissions: JsEmission[] = []; - tokenCache: TokenCache | undefined; + tokenCache: (TokenCache & Partial) | undefined; #listeners: ListenerCallback[] = []; #statusListeners = new Set(); #onLoadedListeners: Array<() => void> = []; @@ -85,7 +94,9 @@ export class FakeClerk { const token = (await this.tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; const response = await request(token); if (response.token) { - await this.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, response.token); + await (this.tokenCache?.saveClientToken + ? this.tokenCache.saveClientToken(response.token, token) + : this.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, response.token)); } return response; } diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts index 0eedec3ac2c..6e308ac447e 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts @@ -15,9 +15,12 @@ export type NativeSyncScenario = { /** Device token the native SDK has persisted from a previous launch. */ nativeDeviceToken?: string | null; nativeModule?: 'present' | 'absent'; + /** The native SDK fails to configure, e.g. a native build that cannot initialize Clerk. */ + failNativeConfigure?: boolean; disableNativeClientSync?: boolean; /** Keep the native SDK in its startup phase until `releaseNativeStartup` is called. */ delayNativeStartup?: boolean; + strictMode?: boolean; }; /** diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts index 146c41f6751..63272a52c61 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts @@ -1,4 +1,4 @@ // Swap this export to run the behavior suite against a different sync engine. -export { renderWithNativeSync } from './currentEngineAdapter'; +export { renderWithNativeSync } from './newEngineAdapter'; export { FakeClerkServer } from './fakeServer'; export type { NativeSyncHarness, NativeSyncScenario } from './harness'; diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx index 8518d6d5ed0..9a8030be429 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx @@ -145,18 +145,16 @@ describe('native client sync behavior', () => { await expectConverged(h); }); - it('never replaces a signed-in JS client with a foreign sessionless native client', async () => { - const { server, token, clientId, sessionIds } = seedDevice(['user_1']); + it('follows native onto a new client when native replaces the shared device token', async () => { + const { server, token } = seedDevice(['user_1']); const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); - const emissionIndex = h.js.emissions.length; await h.run(() => h.native.switchToForeignSessionlessClient()); await h.settle(); - expect(h.js.client?.id).toBe(clientId); - expect(h.js.session?.id).toBe(sessionIds.user_1); - expect(signedOutEmissionsSince(h, emissionIndex)).toEqual([]); - expect(await h.jsDeviceToken()).toBe(token); + expect(h.native.token).not.toBe(token); + expect(h.js.client?.id).toBe(server.clientIdForToken(h.native.token)); + expect(h.js.session).toBeNull(); await expectConverged(h); }); @@ -174,6 +172,35 @@ describe('native client sync behavior', () => { await expectConverged(h); }); + it('signs JS out when a JS request is rejected after native removed the only session', async () => { + const { server, token } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + + h.native.holdChangeNotifications(); + await h.run(() => h.native.signOut()); + await h.run(() => h.js.getSessionToken()); + + expect(h.js.session).toBeNull(); + expect(h.js.client?.signedInSessions).toEqual([]); + await h.run(() => h.native.releaseChangeNotifications()); + await expectConverged(h); + }); + + it('refetches the JS client a bounded number of times for a burst of rejected requests', async () => { + const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1'); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + const jsClientFetchesBefore = server.requestCount('js', 'GET /client'); + + h.native.holdChangeNotifications(); + await h.run(() => h.native.signOutSession(sessionIds.user_1)); + await h.run(() => Promise.all(Array.from({ length: 5 }, () => h.js.getSessionToken()))); + + expect(h.js.session?.id).toBe(sessionIds.user_2); + expect(server.requestCount('js', 'GET /client') - jsClientFetchesBefore).toBeLessThanOrEqual(2); + await h.run(() => h.native.releaseChangeNotifications()); + await expectConverged(h); + }); + it('settles a native change without an echo loop', async () => { const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1'); const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); @@ -185,8 +212,8 @@ describe('native client sync behavior', () => { await h.settle(); await expectConverged(h); - expect(server.requestCount('js', 'GET /client') - jsClientFetchesBefore).toBeLessThanOrEqual(2); - expect(h.native.clientRefreshCount - nativeRefreshesBefore).toBeLessThanOrEqual(1); + expect(server.requestCount('js', 'GET /client') - jsClientFetchesBefore).toBe(1); + expect(h.native.clientRefreshCount - nativeRefreshesBefore).toBe(0); }); }); @@ -273,7 +300,7 @@ describe('native client sync behavior', () => { await expectConverged(h); expect(server.requestCount('js', 'GET /client') - jsClientFetchesBefore).toBeLessThanOrEqual(1); - expect(h.native.clientRefreshCount - nativeRefreshesBefore).toBeLessThanOrEqual(2); + expect(h.native.clientRefreshCount - nativeRefreshesBefore).toBe(1); }); }); @@ -299,6 +326,46 @@ describe('native client sync behavior', () => { await expectConverged(h); }); + it('configures native once when ClerkProvider mounts under StrictMode', async () => { + const { server, token, sessionIds } = seedDevice(['user_1']); + + const h = await start({ server, jsDeviceToken: token, strictMode: true }); + + expect(h.nativeModuleCalls.filter(call => call === 'configureNative')).toHaveLength(1); + expect(h.js.session?.id).toBe(sessionIds.user_1); + await expectConverged(h); + }); + + it('keeps JS working on its own token cache with sync disabled when native fails to configure', async () => { + const { server, token, sessionIds } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token, failNativeConfigure: true }); + + expect(h.js.session?.id).toBe(sessionIds.user_1); + server.rotateTokenOnNextResponse(token); + await h.run(() => h.js.updateProfile()); + await h.settle(); + + const rotatedToken = h.persistentTokenCache?.read(CLERK_CLIENT_JWT_KEY) ?? null; + expect(rotatedToken).not.toBe(token); + expect(await h.jsDeviceToken()).toBe(rotatedToken); + expect(h.native.token).toBe(token); + expect(h.nativeModuleCalls.filter(call => call !== 'configureNative' && call !== 'addListener')).toEqual([]); + await expect(h.run(() => h.awaitJsToNativeSync())).resolves.toBeUndefined(); + }); + + it('mirrors the shared device token into the app token cache', async () => { + const server = new FakeClerkServer(); + const stale = server.seedClient(); + const signedIn = server.seedClient({ users: ['user_1'] }); + + const h = await start({ server, jsDeviceToken: stale.token, nativeDeviceToken: signedIn.token }); + server.rotateTokenOnNextResponse(signedIn.token); + await h.run(() => h.js.updateProfile()); + + await waitFor(() => expect(h.persistentTokenCache?.read(CLERK_CLIENT_JWT_KEY)).toBe(h.native.token)); + expect(h.native.token).not.toBe(signedIn.token); + }); + it('delivers a JS sign-in that happens while native is still starting up', async () => { const h = await start({ delayNativeStartup: true }); @@ -348,6 +415,24 @@ describe('native client sync behavior', () => { expect(h.js.client?.signedInSessions.map(session => session.id)).toContain(sessionId); expect(h.js.session?.id).toBe(sessionId); }); + + it('rejects with environment_unavailable when native does not finish a refresh in time', async () => { + const h = await start({ delayNativeStartup: true }); + await h.run(() => h.js.signIn('user_1')); + + vi.useFakeTimers(); + try { + const barrier = expect(h.awaitJsToNativeSync()).rejects.toMatchObject({ code: 'environment_unavailable' }); + await vi.advanceTimersByTimeAsync(5_000); + await barrier; + } finally { + vi.useRealTimers(); + } + + await h.run(() => h.releaseNativeStartup()); + await expect(h.run(() => h.awaitJsToNativeSync())).resolves.toBeUndefined(); + await expectConverged(h); + }); }); describe('without native client sync', () => { @@ -368,9 +453,8 @@ describe('native client sync behavior', () => { }); }); - describe('known gaps in the current engine', () => { - // Native adopts whatever token JS passes to `configure`, so a stale JS token replaces native's signed-in one. - it.fails('keeps the native signed-in session when JS starts with a stale device token', async () => { + describe('shared device token', () => { + it('keeps the native signed-in session when JS starts with a stale device token', async () => { const server = new FakeClerkServer(); const stale = server.seedClient(); const signedIn = server.seedClient({ users: ['user_1'] }); @@ -381,8 +465,7 @@ describe('native client sync behavior', () => { await expectConverged(h); }); - // Without compare-and-set, each side keeps its own rotation and they end on different tokens. - it.fails('converges on one device token when JS and native rotate it at the same time', async () => { + it('converges on one device token when JS and native rotate it at the same time', async () => { const { server, token, sessionIds } = seedDevice(['user_1']); const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/newEngineAdapter.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/newEngineAdapter.tsx new file mode 100644 index 00000000000..c5d86387b16 --- /dev/null +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/newEngineAdapter.tsx @@ -0,0 +1,162 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { vi } from 'vitest'; + +import { CLERK_CLIENT_JWT_KEY } from '../../../constants'; +import type { ClerkExpoNativeModule } from '../../../utils/native-module'; +import { FakePersistentTokenCache, setActiveEnvironment } from './environment'; +import { FakeClerk } from './fakeClerk'; +import { FakeNativeClerk } from './fakeNativeClerk'; +import { FakeClerkServer } from './fakeServer'; +import type { NativeSyncHarness, RenderWithNativeSync } from './harness'; + +const nativeClientInvalidatedEvent = 'clerkNativeClientInvalidated'; + +/** + * The `ClerkExpo` native module contract of the simplified engine: native storage owns the device token, + * `configureNative` keeps native's own token and only adopts the seed when it has none, `setDeviceToken` is a + * compare-and-set, and any change to native's client or token is signalled without a payload. + */ +function createNativeModule( + native: FakeNativeClerk, + calls: string[], + { failConfigure }: { failConfigure: boolean }, +): ClerkExpoNativeModule { + const listeners = new Set<() => void>(); + native.onChange(() => { + for (const listener of [...listeners]) { + listener(); + } + }); + + const notifyIfChanged = async (change: () => Promise | void) => { + const previousToken = native.token; + const previousClient = JSON.stringify(native.client); + await change(); + const changed = { + client: JSON.stringify(native.client) !== previousClient, + deviceToken: native.token !== previousToken, + }; + if (changed.client || changed.deviceToken) { + native.emitChange(changed, { kind: 'native' }); + } + }; + + return { + addListener: (eventName, listener) => { + calls.push('addListener'); + if (eventName !== nativeClientInvalidatedEvent || !listener) { + return { remove: () => undefined }; + } + const invalidated = () => listener(); + listeners.add(invalidated); + return { remove: () => listeners.delete(invalidated) }; + }, + configureNative: (_publishableKey, seedDeviceToken) => { + calls.push('configureNative'); + if (failConfigure) { + return Promise.reject(new Error('native configure failed')); + } + native.token ??= seedDeviceToken; + native.isConfigured = true; + return Promise.resolve(); + }, + getDeviceToken: () => { + calls.push('getDeviceToken'); + return Promise.resolve(native.token); + }, + setDeviceToken: async (token, expected) => { + calls.push('setDeviceToken'); + if (native.token !== expected) { + return false; + } + await notifyIfChanged(() => { + native.token = token; + }); + return true; + }, + refreshClient: async () => { + calls.push('refreshClient'); + await native.waitForStartup(); + await notifyIfChanged(() => native.refreshClient()); + }, + }; +} + +async function flushMacrotasks(count: number): Promise { + for (let i = 0; i < count; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } +} + +export const renderWithNativeSync: RenderWithNativeSync = async (scenario = {}) => { + const server = scenario.server ?? new FakeClerkServer(); + const native = new FakeNativeClerk(server, scenario.nativeDeviceToken ?? null); + const js = new FakeClerk(server); + const persistentTokenCache = + scenario.persistentTokenCache || scenario.jsDeviceToken !== undefined + ? new FakePersistentTokenCache(scenario.jsDeviceToken ? { [CLERK_CLIENT_JWT_KEY]: scenario.jsDeviceToken } : {}) + : null; + const releaseNativeStartup = scenario.delayNativeStartup ? native.delayStartup() : () => undefined; + const nativeModuleCalls: string[] = []; + + setActiveEnvironment({ + js, + nativeModule: + scenario.nativeModule === 'absent' + ? null + : createNativeModule(native, nativeModuleCalls, { failConfigure: !!scenario.failNativeConfigure }), + }); + + // Fresh engine modules per scenario: the native module is resolved at import time and the native + // configuration and active engine are module singletons. + vi.resetModules(); + const { ClerkProvider } = await import('../../ClerkProvider'); + const { idle, pullFromNative } = await import('../../nativeClientSync'); + + const provider = ( + + ); + const view = render(scenario.strictMode ? {provider} : provider); + + const settle = async () => { + for (let round = 0; round < 8; round++) { + await act(async () => { + await flushMacrotasks(10); + }); + } + }; + + await waitFor(() => { + if (!js.loaded) { + throw new Error('Clerk has not loaded yet'); + } + }); + await settle(); + + const harness: NativeSyncHarness = { + server, + native, + js, + persistentTokenCache, + jsDeviceToken: () => js.currentDeviceToken(), + nativeModuleCalls, + run: async action => { + let result!: Awaited>; + await act(async () => { + result = await action(); + }); + return result; + }, + settle, + awaitJsToNativeSync: () => idle(), + pullNativeToJs: () => pullFromNative(), + releaseNativeStartup, + unmount: () => view.unmount(), + }; + return harness; +}; diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts b/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts deleted file mode 100644 index dde69d9c2f0..00000000000 --- a/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -import type { NativeClientEvent } from '../../hooks/useNativeClientEvents'; -import { - __internal_resetNativeClientSyncCoordinator, - registerNativeToJsSyncHandler, - synchronizeNativeClientToJs, - trackPendingJsToNativeSync, - waitForPendingJsToNativeSync, -} from '../nativeClientSyncCoordinator'; - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void; - const promise = new Promise(innerResolve => { - resolve = innerResolve; - }); - return { promise, resolve }; -} - -function rejectableDeferred(): { promise: Promise; reject: (error: Error) => void } { - let reject!: (error: Error) => void; - const promise = new Promise((_resolve, innerReject) => { - reject = innerReject; - }); - return { promise, reject }; -} - -function nativeClientEvent(issuedAt: number): NativeClientEvent { - return { - issuedAt, - changed: { client: true, deviceToken: true }, - deviceToken: `native-token-${issuedAt}`, - }; -} - -let unregister: (() => void) | undefined; - -beforeEach(() => { - __internal_resetNativeClientSyncCoordinator(); -}); - -afterEach(() => { - vi.useRealTimers(); - unregister?.(); - unregister = undefined; -}); - -describe('native client sync coordinator', () => { - test('preserves a JS-to-native sync failure until a later sync succeeds', async () => { - const error = new Error('native sync failed'); - trackPendingJsToNativeSync(Promise.reject(error)); - - await expect(waitForPendingJsToNativeSync()).rejects.toBe(error); - await expect(waitForPendingJsToNativeSync()).rejects.toBe(error); - - trackPendingJsToNativeSync(Promise.resolve()); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - }); - - test('does not restore an older failure after a newer sync succeeds', async () => { - const olderSync = rejectableDeferred(); - trackPendingJsToNativeSync(olderSync.promise); - trackPendingJsToNativeSync(Promise.resolve()); - - olderSync.reject(new Error('stale native sync failure')); - - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - }); - - test('ignores a tracked synchronization that is invalidated before it rejects', async () => { - const staleSync = rejectableDeferred(); - const invalidate = trackPendingJsToNativeSync(staleSync.promise); - - invalidate(); - staleSync.reject(new Error('canceled native refresh failed')); - await Promise.resolve(); - - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - }); - - test('rejects with environment unavailable when JS-to-native synchronization times out', async () => { - vi.useFakeTimers(); - const pendingSync = deferred(); - trackPendingJsToNativeSync(pendingSync.promise); - - const waiting = expect(waitForPendingJsToNativeSync()).rejects.toMatchObject({ - code: 'environment_unavailable', - message: 'Timed out waiting for the native Clerk client to synchronize.', - }); - - await vi.advanceTimersByTimeAsync(5_000); - await waiting; - pendingSync.resolve(); - }); - - test('honors an extended timeout for native bootstrap synchronization', async () => { - vi.useFakeTimers(); - const pendingSync = deferred(); - trackPendingJsToNativeSync(pendingSync.promise, 15_000); - - const waiting = waitForPendingJsToNativeSync(); - let didSettle = false; - void waiting.then( - () => { - didSettle = true; - }, - () => { - didSettle = true; - }, - ); - - await vi.advanceTimersByTimeAsync(5_000); - expect(didSettle).toBe(false); - - pendingSync.resolve(); - await expect(waiting).resolves.toBeUndefined(); - }); - - test('ignores pending synchronization outcomes from before a reset', async () => { - const staleSync = rejectableDeferred(); - trackPendingJsToNativeSync(staleSync.promise); - - __internal_resetNativeClientSyncCoordinator(); - trackPendingJsToNativeSync(Promise.resolve()); - staleSync.reject(new Error('stale native sync failure')); - - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - }); - - test('waits for an event sync before starting explicit synchronization', async () => { - const eventSync = deferred(); - const explicitSync = deferred(); - const handler = vi.fn((event?: NativeClientEvent | null) => (event ? eventSync.promise : explicitSync.promise)); - unregister = registerNativeToJsSyncHandler(handler); - - const fromEvent = synchronizeNativeClientToJs(nativeClientEvent(1)); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); - - const explicit = synchronizeNativeClientToJs(); - await Promise.resolve(); - expect(handler).toHaveBeenCalledTimes(1); - - eventSync.resolve(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); - expect(handler).toHaveBeenLastCalledWith(); - - explicitSync.resolve(); - await Promise.all([fromEvent, explicit]); - }); - - test('runs a follow-up synchronization when an event arrives during explicit synchronization', async () => { - const explicitSync = deferred(); - const followUpSync = deferred(); - const signOutEvent: NativeClientEvent = { - issuedAt: 1, - changed: { client: true, deviceToken: true }, - deviceToken: null, - }; - const handler = vi - .fn() - .mockImplementationOnce(() => explicitSync.promise) - .mockImplementationOnce(() => followUpSync.promise); - unregister = registerNativeToJsSyncHandler(handler); - - const explicit = synchronizeNativeClientToJs(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); - - const fromEvent = synchronizeNativeClientToJs(signOutEvent); - expect(fromEvent).toBe(explicit); - - explicitSync.resolve(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); - expect(handler).toHaveBeenLastCalledWith(signOutEvent); - - let didFinish = false; - void fromEvent.then(() => { - didFinish = true; - }); - await Promise.resolve(); - expect(didFinish).toBe(false); - - followUpSync.resolve(); - await Promise.all([explicit, fromEvent]); - }); - - test('merges change flags while preserving the latest event snapshot during explicit synchronization', async () => { - const explicitSync = deferred(); - const followUpSync = deferred(); - const handler = vi - .fn() - .mockImplementationOnce(() => explicitSync.promise) - .mockImplementationOnce(() => followUpSync.promise); - unregister = registerNativeToJsSyncHandler(handler); - - const explicit = synchronizeNativeClientToJs(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); - - const clientEvent: NativeClientEvent = { - issuedAt: 1, - changed: { client: true, deviceToken: false }, - deviceToken: 'native-token-1', - }; - const tokenEvent: NativeClientEvent = { - issuedAt: 2, - changed: { client: false, deviceToken: true }, - deviceToken: null, - sourceId: 'native-sign-out', - }; - const fromClientEvent = synchronizeNativeClientToJs(clientEvent); - const fromTokenEvent = synchronizeNativeClientToJs(tokenEvent); - - explicitSync.resolve(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); - expect(handler).toHaveBeenLastCalledWith({ - ...tokenEvent, - changed: { client: true, deviceToken: true }, - }); - - followUpSync.resolve(); - await Promise.all([explicit, fromClientEvent, fromTokenEvent]); - }); - - test('runs a follow-up synchronization for another explicit request', async () => { - const firstSync = deferred(); - const followUpSync = deferred(); - const handler = vi - .fn() - .mockImplementationOnce(() => firstSync.promise) - .mockImplementationOnce(() => followUpSync.promise); - unregister = registerNativeToJsSyncHandler(handler); - - const first = synchronizeNativeClientToJs(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); - - const second = synchronizeNativeClientToJs(); - expect(second).toBe(first); - - firstSync.resolve(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); - - followUpSync.resolve(); - await Promise.all([first, second]); - }); - - test('serializes native event synchronizations in arrival order', async () => { - const firstSync = deferred(); - const secondSync = deferred(); - const handler = vi.fn((event?: NativeClientEvent | null) => - event?.issuedAt === 1 ? firstSync.promise : secondSync.promise, - ); - unregister = registerNativeToJsSyncHandler(handler); - - const first = synchronizeNativeClientToJs(nativeClientEvent(1)); - const second = synchronizeNativeClientToJs(nativeClientEvent(2)); - - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); - expect(handler).toHaveBeenLastCalledWith(nativeClientEvent(1)); - - firstSync.resolve(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); - expect(handler).toHaveBeenLastCalledWith(nativeClientEvent(2)); - - secondSync.resolve(); - await Promise.all([first, second]); - }); -}); diff --git a/packages/expo/src/provider/nativeClientSync.ts b/packages/expo/src/provider/nativeClientSync.ts new file mode 100644 index 00000000000..6920d6afded --- /dev/null +++ b/packages/expo/src/provider/nativeClientSync.ts @@ -0,0 +1,336 @@ +import type { ClientResource, SignedInSessionResource } from '@clerk/shared/types'; + +import { MemoryTokenCache } from '../cache/MemoryTokenCache'; +import type { TokenCache } from '../cache/types'; +import { CLERK_CLIENT_JWT_KEY } from '../constants'; +import type { ClerkExpoNativeModule } from '../utils/native-module'; + +const CONFIGURE_TIMEOUT_MS = 3_000; +const IDLE_TIMEOUT_MS = 5_000; +const INVALIDATED_EVENT = 'clerkNativeClientInvalidated'; + +type FetchableClient = ClientResource & { fetch?: (options?: { fetchMaxTries?: number }) => Promise }; + +export type SyncableClerk = { + addListener: (listener: () => void) => () => void; + client?: ClientResource | null; + session?: SignedInSessionResource | null; + setActive: (params: { session: SignedInSessionResource | null }) => Promise; + updateClient: (client: ClientResource, options?: { __internal_dangerouslySkipEmit?: boolean }) => void; + handleUnauthenticated: (options?: { broadcast?: boolean }) => Promise; + __internal_reloadInitialResources?: () => Promise; + __internal_setActiveInProgress?: boolean; +}; + +/** A token cache whose client JWT writes are compare-and-set against the token the request used. */ +export type ClientTokenCache = TokenCache & { + saveClientToken: (token: string, requestToken: string | null) => Promise; +}; + +type SingleFlight = { run: () => Promise; current: () => Promise | null }; + +type Engine = { + settled: () => Promise; + pull: () => Promise; + refreshFailure: () => { error: unknown } | null; +}; + +const noop = () => undefined; + +let configuration: { publishableKey: string; ready: Promise } | null = null; +let activeEngine: Engine | null = null; + +function warn(message: string, error?: unknown): void { + if (__DEV__) { + console.warn(`[ClerkExpo] ${message}`, error); + } +} + +function withTimeout(promise: Promise, ms: number, error: () => Error): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(error()), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +function singleFlight(task: () => Promise): SingleFlight { + let running: Promise | null = null; + let rerun = false; + const run = (): Promise => { + if (running) { + rerun = true; + return running; + } + running = (async () => { + let failure: { error: unknown } | null = null; + try { + do { + rerun = false; + try { + await task(); + failure = null; + } catch (error) { + failure = { error }; + } + } while (rerun); + } finally { + running = null; + } + if (failure) { + throw failure.error; + } + })(); + return running; + }; + return { run, current: () => running }; +} + +function defaultSession(client: ClientResource | null | undefined): SignedInSessionResource | null { + const sessions = client?.signedInSessions ?? []; + return sessions.find(session => session.id === client?.lastActiveSessionId) ?? sessions[0] ?? null; +} + +function fingerprint(clerk: SyncableClerk): string { + const client = clerk.client; + const user = clerk.session?.user; + return JSON.stringify([ + client?.id ?? null, + client?.lastActiveSessionId ?? null, + client?.signedInSessions.map(session => [session.id, session.status, +session.updatedAt]) ?? [], + user?.id ?? null, + +(user?.updatedAt ?? 0), + ]); +} + +function configureOnce(native: ClerkExpoNativeModule, publishableKey: string, tokenCache: TokenCache) { + if (configuration?.publishableKey !== publishableKey) { + const ready = (async () => { + const seed = (await tokenCache.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + await native.configureNative(publishableKey, seed); + })(); + configuration = { + publishableKey, + ready: withTimeout(ready, CONFIGURE_TIMEOUT_MS, () => new Error('Timed out configuring the native Clerk SDK.')) + .then(() => true) + .catch(error => { + warn('Native client sync is disabled because the native Clerk SDK failed to configure.', error); + return false; + }), + }; + } + return configuration.ready; +} + +/** + * Shares one device token, owned by native storage, between clerk-js and the native Clerk SDK, and + * keeps both clients current by refetching on a payload-free "client changed" signal in each direction. + */ +export function createNativeClientSync( + native: ClerkExpoNativeModule, + publishableKey: string, + getUserTokenCache: () => TokenCache | undefined, +) { + const fallback = () => getUserTokenCache() ?? MemoryTokenCache; + const ready = () => configureOnce(native, publishableKey, fallback()); + let mirroredToken: string | null = null; + let lastFingerprint: string | undefined; + let refreshFailure: { error: unknown } | null = null; + + const mirror = (token: string | null) => { + if (token && token !== mirroredToken) { + mirroredToken = token; + void Promise.resolve(fallback().saveToken(CLERK_CLIENT_JWT_KEY, token)).catch(noop); + } + }; + + const setDeviceToken = (token: string | null, expected: string | null) => + native.setDeviceToken(token, expected).catch(error => { + warn('Failed to update the native device token.', error); + return false; + }); + + const tokenCache: ClientTokenCache = { + getToken: async key => { + if (key !== CLERK_CLIENT_JWT_KEY || !(await ready())) { + return fallback().getToken(key); + } + return native.getDeviceToken().catch(error => { + warn('Failed to read the native device token.', error); + return fallback().getToken(key); + }); + }, + saveToken: async (key, token) => { + if (key !== CLERK_CLIENT_JWT_KEY || !(await ready())) { + return fallback().saveToken(key, token); + } + await tokenCache.saveClientToken(token, await native.getDeviceToken().catch(() => null)); + }, + clearToken: key => fallback().clearToken?.(key), + saveClientToken: async (token, requestToken) => { + if (!(await ready())) { + return fallback().saveToken(CLERK_CLIENT_JWT_KEY, token); + } + const didSet = token === requestToken || (await setDeviceToken(token, requestToken)); + mirror(didSet ? token : await native.getDeviceToken().catch(() => null)); + }, + }; + + const attach = (clerk: SyncableClerk): (() => void) => { + let reconciling: Promise | null = null; + + // Refetches the JS client with the shared device token and follows native's active session. + const pull = async () => { + const client = clerk.client as FetchableClient | null | undefined; + if (typeof client?.fetch === 'function') { + clerk.updateClient(await client.fetch({ fetchMaxTries: 1 })); + } else { + await clerk.__internal_reloadInitialResources?.(); + } + const target = defaultSession(clerk.client); + if (target && clerk.session?.id !== target.id && !reconciling && !clerk.__internal_setActiveInProgress) { + await clerk.setActive({ session: target }); + } + }; + + const fromNative = singleFlight(async () => { + if (await ready()) { + await pull(); + await reconciling; + lastFingerprint = fingerprint(clerk); + } + }); + + const toNative = singleFlight(async () => { + // Let an in-flight native pull settle first so its own emissions are not echoed back. + await fromNative.current()?.catch(noop); + const next = fingerprint(clerk); + if (next === lastFingerprint || !(await ready())) { + return; + } + lastFingerprint = next; + try { + await native.refreshClient(); + refreshFailure = null; + } catch (error) { + lastFingerprint = undefined; + refreshFailure = { error }; + throw error; + } + }); + + const originalUpdateClient = clerk.updateClient; + const updateClient: SyncableClerk['updateClient'] = (client, options) => { + const sessionId = clerk.session?.id; + const fallbackSession = defaultSession(client); + const wasRemoved = !!sessionId && !client.signedInSessions.some(session => session.id === sessionId); + if (!fallbackSession || !(wasRemoved || reconciling)) { + return originalUpdateClient(client, options); + } + // clerk-js would emit a transient signed-out state before another session is activated. + originalUpdateClient(client, { __internal_dangerouslySkipEmit: true }); + if (reconciling || clerk.__internal_setActiveInProgress) { + return; + } + reconciling = clerk + .setActive({ session: fallbackSession }) + .catch(error => { + warn('Failed to activate the remaining session.', error); + originalUpdateClient(client, options); + }) + .finally(() => { + reconciling = null; + }); + }; + + const originalHandleUnauthenticated = clerk.handleUnauthenticated; + let isHandlingUnauthenticated = false; + const handleUnauthenticated: SyncableClerk['handleUnauthenticated'] = async options => { + if (isHandlingUnauthenticated) { + return; + } + isHandlingUnauthenticated = true; + try { + if (await ready()) { + // Native may have moved the client to another session that JS has not seen yet. + const pendingReconcile = reconciling; + await pull(); + // A reconcile that predates this call may be the request that got the 401, so it is not awaited. + if (reconciling !== pendingReconcile) { + await reconciling; + } + if (clerk.session || reconciling) { + return; + } + } + } catch (error) { + warn('Failed to refresh the client from native after an unauthenticated response.', error); + } finally { + isHandlingUnauthenticated = false; + } + return originalHandleUnauthenticated(options); + }; + + clerk.updateClient = updateClient; + clerk.handleUnauthenticated = handleUnauthenticated; + const removeClerkListener = clerk.addListener( + () => void toNative.run().catch(error => warn('Failed to refresh the native client.', error)), + ); + const subscription = native.addListener?.( + INVALIDATED_EVENT, + () => void fromNative.run().catch(error => warn('Failed to refresh the client from native.', error)), + ); + + const engine: Engine = { + settled: async () => { + while (toNative.current() || fromNative.current()) { + await Promise.all([toNative.current()?.catch(noop), fromNative.current()?.catch(noop)]); + } + }, + pull: fromNative.run, + refreshFailure: () => refreshFailure, + }; + activeEngine = engine; + + return () => { + removeClerkListener(); + subscription?.remove(); + if (clerk.updateClient === updateClient) { + clerk.updateClient = originalUpdateClient; + } + if (clerk.handleUnauthenticated === handleUnauthenticated) { + clerk.handleUnauthenticated = originalHandleUnauthenticated; + } + if (activeEngine === engine) { + activeEngine = null; + } + }; + }; + + return { tokenCache, attach }; +} + +/** + * Resolves once pending JS→native and native→JS syncs have settled. Rejects with the latest native + * refresh failure until a later refresh succeeds, or with `environment_unavailable` after a timeout. + */ +export async function idle(): Promise { + const engine = activeEngine; + if (!engine) { + return; + } + await withTimeout(engine.settled(), IDLE_TIMEOUT_MS, () => + Object.assign(new Error('Timed out waiting for the native Clerk client to synchronize.'), { + code: 'environment_unavailable' as const, + }), + ); + const failure = engine.refreshFailure(); + if (failure) { + throw failure.error; + } +} + +/** Refetches the JS client after a native operation and resolves once it has been applied. */ +export function pullFromNative(): Promise { + return activeEngine?.pull() ?? Promise.resolve(); +} diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx deleted file mode 100644 index 5cdb87dc6ae..00000000000 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ /dev/null @@ -1,1296 +0,0 @@ -import type { ClientJSONSnapshot, ClientResource, SignedInSessionResource } from '@clerk/shared/types'; -import { type MutableRefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { Platform } from 'react-native'; - -import { MemoryTokenCache } from '../cache'; -import type { TokenCache } from '../cache/types'; -import { CLERK_CLIENT_JWT_KEY } from '../constants'; -import { type NativeClientEvent, useNativeClientEvents } from '../hooks/useNativeClientEvents'; -import { ClerkExpoModule as NativeClerkModule } from '../utils/native-module'; -import { - registerNativeToJsSyncHandler, - synchronizeNativeClientToJs, - trackPendingJsToNativeSync, -} from './nativeClientSyncCoordinator'; - -const tokenCacheReadTimeoutMs = 1_000; -const nativeDeviceTokenPollIntervalMs = 100; -const nativeDeviceTokenAvailabilityTimeoutMs = 3_000; -const nativeClientSyncSourceIdPrefix = 'clerk-expo-js-sync'; -const unauthenticatedRecoveryCooldownMs = 5_000; -const nativeClientConfigurationMaxAttempts = 2; -const nativeClientConfigurationRetryDelayMs = 250; -const nativeClientBootstrapTimeoutMs = Platform.OS === 'android' ? 35_000 : 10_000; -const useNativeClientBootstrapEffect = Platform.OS === 'ios' || Platform.OS === 'android' ? useLayoutEffect : useEffect; - -export type SyncableClerkInstance = { - addListener?: (listener: () => void, options?: { skipInitialEmit?: boolean }) => () => void; - addOnLoaded?: (listener: () => void) => void; - client?: ClientResource; - handleUnauthenticated?: (options?: { broadcast?: boolean }) => Promise; - loaded?: boolean; - off?: (event: 'status', listener: (status: string) => void) => void; - on?: (event: 'status', listener: (status: string) => void) => void; - session?: SignedInSessionResource | null; - status?: string; - setActive?: (params: { session: SignedInSessionResource | string | null }) => Promise; - updateClient?: (client: ClientResource, options?: { __internal_dangerouslySkipEmit?: boolean }) => void; - __internal_setActiveInProgress?: boolean; - __internal_reloadInitialResources?: () => void | Promise; -}; - -type RefreshableClientResource = ClientResource & { - fetch?: (options?: { fetchMaxTries?: number }) => Promise; - fromJSON?: (data: ClientJSONSnapshot) => ClientResource; -}; - -type NativeRefreshFromJsOptions = { - deviceToken?: string | null; - didChangeClient: boolean; - didChangeDeviceToken: boolean; -}; - -type NativeClientSyncCompletion = { - invalidateTracking: () => void; - promise: Promise; - resolve: () => void; -}; - -type NativeClientBootstrapRegistration = { - clerkInstance: SyncableClerkInstance | null | undefined; - generation: number; - invalidateTracking: () => void; - publishableKey: string; - tokenCache: TokenCache | undefined; -}; - -export type NativeRefreshFromJsController = { - cancel: () => void; - syncDeviceTokenToNative: (deviceToken: string | null) => void; -}; - -export type DeviceTokenCacheListener = (deviceToken: string | null) => void; - -function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function createNativeClientSyncCompletion(): NativeClientSyncCompletion { - let resolve!: () => void; - const promise = new Promise(innerResolve => { - resolve = innerResolve; - }); - return { invalidateTracking: () => undefined, promise, resolve }; -} - -export function useSyncableTokenCache({ - suppressTokenCacheNotificationsRef, - tokenCache, - tokenCacheListenersRef, -}: { - suppressTokenCacheNotificationsRef: MutableRefObject; - tokenCache: TokenCache | undefined; - tokenCacheListenersRef: MutableRefObject>; -}): TokenCache | undefined { - return useMemo(() => { - const effectiveTokenCache = - tokenCache ?? (Platform.OS === 'ios' || Platform.OS === 'android' ? MemoryTokenCache : undefined); - if (!effectiveTokenCache) { - return undefined; - } - - let hasKnownDeviceToken = false; - let knownDeviceToken: string | null = null; - - const notifyDeviceTokenListeners = (deviceToken: string | null) => { - if (suppressTokenCacheNotificationsRef.current > 0) { - return; - } - - for (const listener of tokenCacheListenersRef.current) { - listener(deviceToken); - } - }; - - return { - getToken: async key => { - const token = await effectiveTokenCache.getToken(key); - if (key === CLERK_CLIENT_JWT_KEY && !hasKnownDeviceToken) { - hasKnownDeviceToken = true; - knownDeviceToken = token ?? null; - } - return token; - }, - saveToken: async (key, token) => { - await effectiveTokenCache.saveToken(key, token); - if (key === CLERK_CLIENT_JWT_KEY) { - const didChange = !hasKnownDeviceToken || knownDeviceToken !== token; - hasKnownDeviceToken = true; - knownDeviceToken = token; - if (didChange) { - notifyDeviceTokenListeners(token); - } - } - }, - clearToken: async key => { - await effectiveTokenCache.clearToken?.(key); - if (key === CLERK_CLIENT_JWT_KEY) { - const didChange = !hasKnownDeviceToken || knownDeviceToken !== null; - hasKnownDeviceToken = true; - knownDeviceToken = null; - if (didChange) { - notifyDeviceTokenListeners(null); - } - } - }, - }; - }, [suppressTokenCacheNotificationsRef, tokenCache, tokenCacheListenersRef]); -} - -async function readNativeDeviceToken({ waitForToken }: { waitForToken: boolean }): Promise { - const ClerkExpo = NativeClerkModule; - if (!ClerkExpo?.getClientToken) { - return null; - } - - const startedAt = Date.now(); - let remainingMs = nativeDeviceTokenAvailabilityTimeoutMs; - - do { - const nativeDeviceToken = await ClerkExpo.getClientToken(); - if (nativeDeviceToken) { - return nativeDeviceToken; - } - - if (!waitForToken) { - return null; - } - - remainingMs = nativeDeviceTokenAvailabilityTimeoutMs - (Date.now() - startedAt); - if (remainingMs <= 0) { - return null; - } - - await delay(Math.min(nativeDeviceTokenPollIntervalMs, remainingMs)); - } while (remainingMs > 0); - - return null; -} - -async function syncDeviceTokenToCache(tokenCache: TokenCache | undefined, deviceToken: string | null): Promise { - if (deviceToken) { - await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, deviceToken); - return; - } - - await tokenCache?.clearToken?.(CLERK_CLIENT_JWT_KEY); -} - -async function syncDeviceTokenToCacheWithoutNotifying({ - deviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - deviceToken: string | null; - suppressTokenCacheNotificationsRef: MutableRefObject; - tokenCache: TokenCache | undefined; -}): Promise { - suppressTokenCacheNotificationsRef.current += 1; - try { - await syncDeviceTokenToCache(tokenCache, deviceToken); - } finally { - suppressTokenCacheNotificationsRef.current = Math.max(0, suppressTokenCacheNotificationsRef.current - 1); - } -} - -async function syncNativeDeviceTokenToCache({ - deviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - deviceToken: string | null; - suppressTokenCacheNotificationsRef?: MutableRefObject; - tokenCache: TokenCache | undefined; -}): Promise { - if (suppressTokenCacheNotificationsRef) { - await syncDeviceTokenToCacheWithoutNotifying({ - deviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - return; - } - - await syncDeviceTokenToCache(tokenCache, deviceToken); -} - -function getDefaultSignedInSession(client: ClientResource | null | undefined): SignedInSessionResource | null { - 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; -} - -function canRefreshJsClientFromServer(clerkInstance: SyncableClerkInstance): boolean { - const client = clerkInstance.client as RefreshableClientResource | undefined; - - return typeof client?.fetch === 'function' && typeof clerkInstance.updateClient === 'function'; -} - -function fetchRefreshedJsClient(clerkInstance: SyncableClerkInstance): Promise { - const client = clerkInstance.client as RefreshableClientResource | undefined; - - if (typeof client?.fetch !== 'function' || typeof clerkInstance.updateClient !== 'function') { - return Promise.resolve(null); - } - - return client.fetch({ fetchMaxTries: 1 }); -} - -type ClientStateSnapshot = { - id: string | null; - hasSignedInSession: boolean; - restore: (() => ClientResource) | null; -}; - -function snapshotClientState(client: ClientResource | null | undefined): ClientStateSnapshot { - const resource = client as RefreshableClientResource | undefined; - const fromJSON = resource?.fromJSON?.bind(resource); - let restore: ClientStateSnapshot['restore'] = null; - - if (resource && fromJSON) { - const state = resource.__internal_toSnapshot(); - restore = () => fromJSON(state); - } - - return { - id: client?.id ?? null, - hasSignedInSession: Boolean(client && getDefaultSignedInSession(client)), - restore, - }; -} - -// Client.fetch mutates the resource, so compare against pre-fetch values. -function isForeignSessionlessClient(previousSnapshot: ClientStateSnapshot, refreshedClient: ClientResource): boolean { - if (!previousSnapshot.id || !refreshedClient.id || previousSnapshot.id === refreshedClient.id) { - return false; - } - - return previousSnapshot.hasSignedInSession && refreshedClient.signedInSessions.length === 0; -} - -async function refreshJsClientFromNativeState({ - clerkInstance, - nativeDeviceToken, - previousDeviceToken, - rejectForeignSessionlessClient = false, - reloadInitialResources, - shouldSyncDeviceToken = true, - suppressDeviceTokenRollbackNotification = false, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - clerkInstance: SyncableClerkInstance; - nativeDeviceToken: string | null; - previousDeviceToken?: string | null; - rejectForeignSessionlessClient?: boolean; - reloadInitialResources: boolean; - shouldSyncDeviceToken?: boolean; - suppressDeviceTokenRollbackNotification?: boolean; - suppressTokenCacheNotificationsRef?: MutableRefObject; - tokenCache: TokenCache | undefined; -}): Promise { - const previousClientSnapshot = snapshotClientState(clerkInstance.client); - - const restorePreviousDeviceToken = async () => { - if (!rejectForeignSessionlessClient || !shouldSyncDeviceToken || previousDeviceToken === undefined) { - 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. - await syncNativeDeviceTokenToCache({ - deviceToken: previousDeviceToken, - suppressTokenCacheNotificationsRef: suppressDeviceTokenRollbackNotification - ? suppressTokenCacheNotificationsRef - : undefined, - tokenCache, - }); - }; - - let refreshedClient: ClientResource | null; - try { - if (shouldSyncDeviceToken) { - await syncNativeDeviceTokenToCache({ - deviceToken: nativeDeviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - } - - refreshedClient = await fetchRefreshedJsClient(clerkInstance); - } catch (error) { - await restorePreviousDeviceToken(); - throw error; - } - - if (refreshedClient) { - if (rejectForeignSessionlessClient && isForeignSessionlessClient(previousClientSnapshot, refreshedClient)) { - await restorePreviousDeviceToken(); - const restoredClient = previousClientSnapshot.restore?.(); - if (restoredClient) { - clerkInstance.updateClient?.(restoredClient); - await reconcileJsActiveSessionFromClient({ - clerkInstance, - }); - } - return 'restored'; - } - - clerkInstance.updateClient?.(refreshedClient); - await reconcileJsActiveSessionFromClient({ - clerkInstance, - }); - return 'refreshed'; - } - - if (reloadInitialResources && typeof clerkInstance.__internal_reloadInitialResources === 'function') { - await clerkInstance.__internal_reloadInitialResources(); - await reconcileJsActiveSessionFromClient({ - clerkInstance, - }); - return getDefaultSignedInSession(clerkInstance.client) ? 'refreshed' : false; - } - - return false; -} - -async function reloadJsClientFromNativeState({ - clerkInstance, - nativeDeviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - clerkInstance: SyncableClerkInstance; - nativeDeviceToken: string; - suppressTokenCacheNotificationsRef?: MutableRefObject; - tokenCache: TokenCache | undefined; -}): Promise { - await syncNativeDeviceTokenToCache({ - deviceToken: nativeDeviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - - await clerkInstance.__internal_reloadInitialResources?.(); - await reconcileJsActiveSessionFromClient({ - clerkInstance, - }); - return Boolean(getDefaultSignedInSession(clerkInstance.client)); -} - -async function recoverJsClientFromNativeDeviceToken({ - clerkInstance, - error, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - clerkInstance: SyncableClerkInstance; - error: unknown; - suppressTokenCacheNotificationsRef: MutableRefObject; - tokenCache: TokenCache | undefined; -}): Promise { - const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); - if (!nativeDeviceToken) { - return false; - } - - if (__DEV__) { - console.warn('[NativeClientSync] Failed to refresh JS client with native device token:', error); - } - - try { - return await reloadJsClientFromNativeState({ - clerkInstance, - nativeDeviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - } catch (recoveryError) { - if (__DEV__) { - console.warn('[NativeClientSync] Failed to recover JS client after unauthenticated state:', recoveryError); - } - return false; - } -} - -async function reconcileJsActiveSessionFromClient({ - clerkInstance, -}: { - clerkInstance: SyncableClerkInstance; -}): Promise { - const fallbackSession = getDefaultSignedInSession(clerkInstance.client); - if (!fallbackSession || typeof clerkInstance.setActive !== 'function') { - return; - } - - const currentSession = clerkInstance.session; - const currentSessionStillExists = currentSession - ? clerkInstance.client?.signedInSessions.some(session => session.id === currentSession.id) - : false; - - if (currentSessionStillExists && currentSession?.id === fallbackSession.id) { - return; - } - - await clerkInstance.setActive({ session: fallbackSession }); -} - -async function runWithSuppressedJsClientChanges( - suppressJsClientChangedRef: MutableRefObject | undefined, - task: () => Promise, -): Promise { - if (!suppressJsClientChangedRef) { - return task(); - } - - suppressJsClientChangedRef.current += 1; - try { - return await task(); - } finally { - suppressJsClientChangedRef.current = Math.max(0, suppressJsClientChangedRef.current - 1); - } -} - -function mergePendingNativeRefreshOptions( - current: NativeRefreshFromJsOptions | null, - next: NativeRefreshFromJsOptions, -): NativeRefreshFromJsOptions { - if (!current) { - return next; - } - - const merged: NativeRefreshFromJsOptions = { - didChangeClient: current.didChangeClient || next.didChangeClient, - didChangeDeviceToken: current.didChangeDeviceToken || next.didChangeDeviceToken, - }; - - if ('deviceToken' in current) { - merged.deviceToken = current.deviceToken ?? null; - } - - if ('deviceToken' in next) { - merged.deviceToken = next.deviceToken ?? null; - } - - return merged; -} - -const tokenCacheReadTimedOut = Symbol('tokenCacheReadTimedOut'); - -// `undefined` = read timed out, `null` = confirmed missing token. -async function getCachedDeviceToken(tokenCache: TokenCache | undefined): Promise { - if (!tokenCache) { - return null; - } - - let timeoutId: ReturnType | undefined; - try { - const result = await Promise.race([ - tokenCache.getToken(CLERK_CLIENT_JWT_KEY), - new Promise(resolve => { - timeoutId = setTimeout(() => resolve(tokenCacheReadTimedOut), tokenCacheReadTimeoutMs); - }), - ]); - if (result === tokenCacheReadTimedOut) { - return undefined; - } - return result ?? null; - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } -} - -async function syncNativeClientToJs({ - clerkInstance, - nativeRefreshFromJsControllerRef, - nativeClientEvent, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - clerkInstance: SyncableClerkInstance; - nativeRefreshFromJsControllerRef?: MutableRefObject; - nativeClientEvent?: NativeClientEvent | null; - suppressJsClientChangedRef?: MutableRefObject; - suppressTokenCacheNotificationsRef?: MutableRefObject; - tokenCache: TokenCache | undefined; -}): Promise { - const didChangeClient = nativeClientEvent?.changed.client ?? true; - const didChangeDeviceToken = nativeClientEvent?.changed.deviceToken ?? true; - - if (!didChangeClient && !didChangeDeviceToken) { - return; - } - - const nativeDeviceToken = nativeClientEvent - ? nativeClientEvent.deviceToken - : await readNativeDeviceToken({ - waitForToken: true, - }); - - if (!nativeDeviceToken && !nativeClientEvent) { - return; - } - - const previousDeviceToken = didChangeDeviceToken ? await getCachedDeviceToken(tokenCache) : undefined; - const hasSignedInJsClient = Boolean(getDefaultSignedInSession(clerkInstance.client)); - - if (didChangeDeviceToken && hasSignedInJsClient) { - // Timed-out cache read leaves no rollback snapshot, so keep JS authoritative. - if (previousDeviceToken === undefined) { - return; - } - - if (previousDeviceToken && !canRefreshJsClientFromServer(clerkInstance)) { - nativeRefreshFromJsControllerRef?.current?.syncDeviceTokenToNative(previousDeviceToken); - return; - } - } - - await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { - nativeRefreshFromJsControllerRef?.current?.cancel(); - - await refreshJsClientFromNativeState({ - clerkInstance, - nativeDeviceToken, - previousDeviceToken, - rejectForeignSessionlessClient: true, - reloadInitialResources: true, - shouldSyncDeviceToken: didChangeDeviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - }); -} - -/** - * Syncs JS SDK client changes to the native Clerk SDK so native components - * (UserButton, UserProfileView) stay in sync after JS-owned resource changes. - * - * Must be rendered inside `ClerkReactProvider` so the Clerk instance has loaded - * resources to emit. - */ -export function NativeClientSync({ - enabled, - clerkInstance, - nativeRefreshFromJsControllerRef, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, - tokenCacheListenersRef, -}: { - enabled: boolean; - clerkInstance: SyncableClerkInstance | null | undefined; - nativeRefreshFromJsControllerRef: MutableRefObject; - suppressJsClientChangedRef: MutableRefObject; - suppressTokenCacheNotificationsRef: MutableRefObject; - tokenCache: TokenCache | undefined; - tokenCacheListenersRef: MutableRefObject>; -}): null { - const isRefreshingNativeFromJsRef = useRef(false); - const nativeRefreshPromiseRef = useRef | null>(null); - const invalidateTrackedNativeRefreshRef = useRef<(() => void) | null>(null); - const pendingNativeRefreshRef = useRef(null); - const pendingNativeRefreshBeforeReadyRef = useRef(null); - const pendingNativeRefreshBeforeReadyCompletionRef = useRef(null); - const nativeRefreshGenerationRef = useRef(0); - const lastUnauthenticatedRecoveryRef = useRef(undefined); - const enabledRef = useRef(enabled); - enabledRef.current = enabled; - - const queueNativeRefreshBeforeReady = useCallback((options: NativeRefreshFromJsOptions) => { - pendingNativeRefreshBeforeReadyRef.current = mergePendingNativeRefreshOptions( - pendingNativeRefreshBeforeReadyRef.current, - options, - ); - if (!pendingNativeRefreshBeforeReadyCompletionRef.current) { - const completion = createNativeClientSyncCompletion(); - pendingNativeRefreshBeforeReadyCompletionRef.current = completion; - completion.invalidateTracking = trackPendingJsToNativeSync(completion.promise); - } - }, []); - - const cancelNativeRefreshFromJs = useCallback(() => { - invalidateTrackedNativeRefreshRef.current?.(); - invalidateTrackedNativeRefreshRef.current = null; - pendingNativeRefreshRef.current = null; - pendingNativeRefreshBeforeReadyRef.current = null; - pendingNativeRefreshBeforeReadyCompletionRef.current?.invalidateTracking(); - pendingNativeRefreshBeforeReadyCompletionRef.current?.resolve(); - pendingNativeRefreshBeforeReadyCompletionRef.current = null; - nativeRefreshGenerationRef.current += 1; - isRefreshingNativeFromJsRef.current = false; - nativeRefreshPromiseRef.current = null; - }, []); - - useEffect(() => { - if ( - !clerkInstance || - typeof clerkInstance.updateClient !== 'function' || - typeof clerkInstance.setActive !== 'function' - ) { - return; - } - - const originalUpdateClient = clerkInstance.updateClient.bind(clerkInstance); - let isReconcilingRemovedActiveSession = false; - - const updateClient: SyncableClerkInstance['updateClient'] = (newClient, options) => { - const currentSessionId = clerkInstance.session?.id; - const fallbackSession = getDefaultSignedInSession(newClient); - const currentSessionWasRemoved = currentSessionId - ? !newClient.signedInSessions.some(session => session.id === currentSessionId) - : false; - const alreadyReconcilingRemovedActiveSession = isReconcilingRemovedActiveSession; - - if ((currentSessionWasRemoved || alreadyReconcilingRemovedActiveSession) && fallbackSession) { - // Clerk JS briefly emits signed-out when the active session disappears, - // even if the refreshed client still has another signed-in session. - // Keep that transient state internal so native session switching does - // not dismiss mounted native UI before setActive settles on JS. - originalUpdateClient(newClient, { __internal_dangerouslySkipEmit: true }); - - if (clerkInstance.__internal_setActiveInProgress || alreadyReconcilingRemovedActiveSession) { - return; - } - - isReconcilingRemovedActiveSession = true; - void runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { - try { - await clerkInstance.setActive?.({ session: fallbackSession }); - } catch (error) { - if (__DEV__) { - console.warn('[NativeClientSync] Failed to set remaining active JS session:', error); - } - originalUpdateClient(newClient, options); - } finally { - isReconcilingRemovedActiveSession = false; - } - }); - return; - } - - if (options) { - originalUpdateClient(newClient, options); - return; - } - - originalUpdateClient(newClient); - }; - - clerkInstance.updateClient = updateClient; - - return () => { - if (clerkInstance.updateClient === updateClient) { - clerkInstance.updateClient = originalUpdateClient; - } - }; - }, [clerkInstance, suppressJsClientChangedRef]); - - const queueNativeRefreshFromJs = useCallback((options: NativeRefreshFromJsOptions): Promise => { - if (isRefreshingNativeFromJsRef.current) { - pendingNativeRefreshRef.current = mergePendingNativeRefreshOptions(pendingNativeRefreshRef.current, options); - nativeRefreshGenerationRef.current += 1; - return nativeRefreshPromiseRef.current ?? Promise.resolve(); - } - - const initialGeneration = nativeRefreshGenerationRef.current + 1; - nativeRefreshGenerationRef.current = initialGeneration; - isRefreshingNativeFromJsRef.current = true; - - const refreshNativeFromJsClient = async ( - options: NativeRefreshFromJsOptions, - generation: number, - ): Promise => { - const ClerkExpo = NativeClerkModule; - if (!ClerkExpo || generation !== nativeRefreshGenerationRef.current) { - return; - } - - const deviceToken = options.didChangeDeviceToken ? (options.deviceToken ?? null) : null; - if (generation !== nativeRefreshGenerationRef.current) { - return; - } - - const sourceId = `${nativeClientSyncSourceIdPrefix}-${generation}`; - await ClerkExpo.syncClientStateFromJs( - deviceToken, - sourceId, - options.didChangeClient, - options.didChangeDeviceToken, - ); - }; - - const nativeRefreshPromise = (async () => { - let pendingOptions = options; - let generation = initialGeneration; - let refreshError: unknown; - let didRefreshFail = false; - do { - pendingNativeRefreshRef.current = null; - try { - await refreshNativeFromJsClient(pendingOptions, generation); - refreshError = undefined; - didRefreshFail = false; - } catch (error: unknown) { - refreshError = error; - didRefreshFail = true; - if (__DEV__) { - console.warn('[NativeClientSync] Failed to refresh native client from JS client change:', error); - } - if (pendingNativeRefreshRef.current) { - pendingNativeRefreshRef.current = mergePendingNativeRefreshOptions( - pendingOptions, - pendingNativeRefreshRef.current, - ); - } - } - pendingOptions = pendingNativeRefreshRef.current ?? { - didChangeClient: false, - didChangeDeviceToken: false, - }; - if (pendingNativeRefreshRef.current !== null) { - generation = nativeRefreshGenerationRef.current + 1; - nativeRefreshGenerationRef.current = generation; - } - } while (pendingNativeRefreshRef.current !== null); - - if (didRefreshFail) { - throw refreshError; - } - })(); - const finishNativeRefresh = () => { - if (nativeRefreshPromiseRef.current === nativeRefreshPromise) { - isRefreshingNativeFromJsRef.current = false; - nativeRefreshPromiseRef.current = null; - invalidateTrackedNativeRefreshRef.current = null; - } - }; - - nativeRefreshPromiseRef.current = nativeRefreshPromise; - void nativeRefreshPromise.then(finishNativeRefresh, finishNativeRefresh); - invalidateTrackedNativeRefreshRef.current = trackPendingJsToNativeSync(nativeRefreshPromise); - return nativeRefreshPromise; - }, []); - - useEffect(() => { - nativeRefreshFromJsControllerRef.current = { - cancel: cancelNativeRefreshFromJs, - syncDeviceTokenToNative: deviceToken => { - void queueNativeRefreshFromJs({ - deviceToken, - didChangeClient: false, - didChangeDeviceToken: true, - }); - }, - }; - - return () => { - if (nativeRefreshFromJsControllerRef.current?.cancel === cancelNativeRefreshFromJs) { - nativeRefreshFromJsControllerRef.current = null; - } - }; - }, [cancelNativeRefreshFromJs, nativeRefreshFromJsControllerRef, queueNativeRefreshFromJs]); - - useEffect(() => { - if (!enabled) { - return; - } - - if (pendingNativeRefreshBeforeReadyRef.current) { - const pendingOptions = pendingNativeRefreshBeforeReadyRef.current; - const pendingCompletion = pendingNativeRefreshBeforeReadyCompletionRef.current; - pendingNativeRefreshBeforeReadyRef.current = null; - pendingNativeRefreshBeforeReadyCompletionRef.current = null; - void queueNativeRefreshFromJs(pendingOptions).then(pendingCompletion?.resolve, pendingCompletion?.resolve); - } - }, [enabled, queueNativeRefreshFromJs]); - - useEffect(() => { - return cancelNativeRefreshFromJs; - }, [cancelNativeRefreshFromJs]); - - 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, - didChangeDeviceToken: true, - }; - - if (!enabledRef.current) { - if (clerkInstance?.loaded) { - queueNativeRefreshBeforeReady(options); - } - return; - } - - void queueNativeRefreshFromJs(options); - }; - const tokenCacheListeners = tokenCacheListenersRef.current; - - tokenCacheListeners.add(listener); - return () => { - tokenCacheListeners.delete(listener); - }; - }, [clerkInstance, queueNativeRefreshBeforeReady, queueNativeRefreshFromJs, tokenCacheListenersRef]); - - useEffect(() => { - if (!clerkInstance || typeof clerkInstance.handleUnauthenticated !== 'function') { - return; - } - - const originalHandleUnauthenticated = clerkInstance.handleUnauthenticated.bind(clerkInstance); - let isHandlingUnauthenticated = false; - - const handleUnauthenticated: SyncableClerkInstance['handleUnauthenticated'] = async options => { - if (isHandlingUnauthenticated) { - return; - } - - isHandlingUnauthenticated = true; - try { - // Re-reading native state and refetching the client for every response in a 401 burst only amplifies it. - const lastRecovery = lastUnauthenticatedRecoveryRef.current; - 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 = Date.now(); - - 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 result = await refreshJsClientFromNativeState({ - clerkInstance, - nativeDeviceToken, - previousDeviceToken, - rejectForeignSessionlessClient: true, - reloadInitialResources: false, - suppressDeviceTokenRollbackNotification: true, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - // 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) { - const didRecover = await recoverJsClientFromNativeDeviceToken({ - clerkInstance, - error, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - if (didRecover) { - return; - } - } - - 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; - } - }; - - clerkInstance.handleUnauthenticated = handleUnauthenticated; - - return () => { - if (clerkInstance.handleUnauthenticated === handleUnauthenticated) { - clerkInstance.handleUnauthenticated = originalHandleUnauthenticated; - } - }; - }, [ - clerkInstance, - nativeRefreshFromJsControllerRef, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, - ]); - - useEffect(() => { - if (!clerkInstance || typeof clerkInstance.addListener !== 'function') { - return; - } - - const unsubscribe = clerkInstance.addListener( - () => { - if (suppressJsClientChangedRef.current > 0) { - return; - } - - if (!enabledRef.current) { - if (clerkInstance.loaded) { - queueNativeRefreshBeforeReady({ - didChangeClient: true, - didChangeDeviceToken: false, - }); - } - return; - } - - void queueNativeRefreshFromJs({ - didChangeClient: true, - didChangeDeviceToken: false, - }); - }, - { skipInitialEmit: true }, - ); - - return () => { - unsubscribe(); - }; - }, [clerkInstance, queueNativeRefreshBeforeReady, queueNativeRefreshFromJs, suppressJsClientChangedRef]); - - return null; -} - -function waitForClerkInstanceLoad(clerkInstance: SyncableClerkInstance): Promise { - if (clerkInstance.loaded) { - return Promise.resolve(); - } - - if (typeof clerkInstance.on === 'function' && typeof clerkInstance.off === 'function') { - return new Promise(resolve => { - let didSettle = false; - const settle = () => { - if (didSettle) { - return; - } - didSettle = true; - clerkInstance.off?.('status', handleStatus); - resolve(); - }; - const handleStatus = (status: string) => { - if (status === 'ready' || status === 'degraded' || status === 'error') { - settle(); - } - }; - - clerkInstance.on?.('status', handleStatus); - if ( - clerkInstance.loaded || - clerkInstance.status === 'ready' || - clerkInstance.status === 'degraded' || - clerkInstance.status === 'error' - ) { - settle(); - } - }); - } - - if (typeof clerkInstance.addOnLoaded === 'function') { - return new Promise(resolve => clerkInstance.addOnLoaded?.(resolve)); - } - - if (__DEV__) { - console.warn('[ClerkProvider] Clerk instance has no load status listener'); - } - return Promise.resolve(); -} - -export function useNativeClientBootstrap({ - enabled, - publishableKey, - nativeRefreshFromJsControllerRef, - suppressTokenCacheNotificationsRef, - tokenCache, - clerkInstance, -}: { - enabled: boolean; - publishableKey: string; - nativeRefreshFromJsControllerRef: MutableRefObject; - suppressTokenCacheNotificationsRef: MutableRefObject; - tokenCache: TokenCache | undefined; - clerkInstance: SyncableClerkInstance | null | undefined; -}) { - const activeBootstrapRef = useRef(null); - const bootstrapGenerationRef = useRef(0); - const isMountedRef = useRef(true); - const [readyPublishableKey, setReadyPublishableKey] = useState(null); - - useNativeClientBootstrapEffect(() => { - isMountedRef.current = true; - const canBootstrap = enabled && (Platform.OS === 'ios' || Platform.OS === 'android') && Boolean(publishableKey); - const activeBootstrap = activeBootstrapRef.current; - const canReuseActiveBootstrap = - canBootstrap && - activeBootstrap?.publishableKey === publishableKey && - activeBootstrap.clerkInstance === clerkInstance && - activeBootstrap.tokenCache === tokenCache; - - if (activeBootstrap && !canReuseActiveBootstrap) { - activeBootstrap.invalidateTracking(); - activeBootstrapRef.current = null; - setReadyPublishableKey(null); - } - - if (canBootstrap && !activeBootstrapRef.current) { - const configuringPublishableKey = publishableKey; - const bootstrapRegistration: NativeClientBootstrapRegistration = { - clerkInstance, - generation: ++bootstrapGenerationRef.current, - invalidateTracking: () => undefined, - publishableKey: configuringPublishableKey, - tokenCache, - }; - activeBootstrapRef.current = bootstrapRegistration; - setReadyPublishableKey(null); - const isCurrentConfiguration = () => - isMountedRef.current && - activeBootstrapRef.current === bootstrapRegistration && - bootstrapGenerationRef.current === bootstrapRegistration.generation; - - const configureNativeClerk = async () => { - try { - const ClerkExpo = NativeClerkModule; - - if (ClerkExpo?.configure) { - if (clerkInstance) { - await waitForClerkInstanceLoad(clerkInstance); - - if (!isCurrentConfiguration()) { - return; - } - } - - let initialJsDeviceToken: string | null = null; - try { - initialJsDeviceToken = (await getCachedDeviceToken(tokenCache)) ?? null; - } catch (e) { - if (__DEV__) { - console.warn('[ClerkProvider] Token cache read failed:', e); - } - } - - if (!isCurrentConfiguration()) { - return; - } - - await ClerkExpo.configure(configuringPublishableKey, initialJsDeviceToken); - - if (!isCurrentConfiguration()) { - return; - } - - if (clerkInstance) { - const currentJsDeviceToken = (await getCachedDeviceToken(tokenCache)) ?? null; - const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); - - if (!isCurrentConfiguration()) { - return; - } - - if (currentJsDeviceToken !== nativeDeviceToken) { - if ( - !nativeDeviceToken || - (initialJsDeviceToken !== null && currentJsDeviceToken !== initialJsDeviceToken) - ) { - nativeRefreshFromJsControllerRef.current?.cancel(); - await ClerkExpo.syncClientStateFromJs( - currentJsDeviceToken, - `${nativeClientSyncSourceIdPrefix}-bootstrap`, - true, - true, - ); - } else { - await syncNativeClientToJs({ - clerkInstance, - nativeRefreshFromJsControllerRef, - nativeClientEvent: { - changed: { client: true, deviceToken: true }, - deviceToken: nativeDeviceToken, - issuedAt: Date.now(), - }, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - } - } - } - - if (isCurrentConfiguration()) { - setReadyPublishableKey(configuringPublishableKey); - } - } - } catch (error) { - const isNativeModuleNotFound = error instanceof Error && error.message.includes('Cannot find native module'); - if (isNativeModuleNotFound) { - if (__DEV__) { - console.debug( - `[ClerkProvider] Native Clerk module not available. ` + - `To enable native features, install @clerk/expo-native-components and add "@clerk/expo-native-components" to your app.json plugins array.`, - ); - } - } else if (__DEV__) { - console.error(`[ClerkProvider] Failed to configure Clerk ${Platform.OS}:`, error); - } - throw error; - } - }; - const configureNativeClerkWithRetry = async () => { - for (let attempt = 1; attempt <= nativeClientConfigurationMaxAttempts; attempt++) { - try { - await configureNativeClerk(); - return; - } catch (error) { - const isNativeModuleNotFound = - error instanceof Error && error.message.includes('Cannot find native module'); - if ( - !isCurrentConfiguration() || - isNativeModuleNotFound || - attempt === nativeClientConfigurationMaxAttempts - ) { - if (isCurrentConfiguration()) { - nativeRefreshFromJsControllerRef.current?.cancel(); - } - throw error; - } - - await new Promise(resolve => setTimeout(resolve, nativeClientConfigurationRetryDelayMs)); - if (!isCurrentConfiguration()) { - return; - } - } - } - }; - const nativeClientBootstrap = configureNativeClerkWithRetry(); - bootstrapRegistration.invalidateTracking = trackPendingJsToNativeSync( - nativeClientBootstrap, - nativeClientBootstrapTimeoutMs, - ); - void nativeClientBootstrap; - } - - return () => { - isMountedRef.current = false; - const bootstrapRegistration = activeBootstrapRef.current; - queueMicrotask(() => { - if (!isMountedRef.current && activeBootstrapRef.current === bootstrapRegistration) { - bootstrapRegistration?.invalidateTracking(); - activeBootstrapRef.current = null; - } - }); - }; - }, [ - enabled, - publishableKey, - nativeRefreshFromJsControllerRef, - suppressTokenCacheNotificationsRef, - tokenCache, - clerkInstance, - ]); - - return { - isMountedRef, - isNativeClientReady: readyPublishableKey === publishableKey, - }; -} - -export function useNativeClientEventSync({ - enabled, - clerkInstance, - isMountedRef, - nativeRefreshFromJsControllerRef, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - enabled: boolean; - clerkInstance: SyncableClerkInstance | null | undefined; - isMountedRef: MutableRefObject; - nativeRefreshFromJsControllerRef: MutableRefObject; - suppressJsClientChangedRef: MutableRefObject; - suppressTokenCacheNotificationsRef: MutableRefObject; - tokenCache: TokenCache | undefined; -}) { - const { nativeClientEvent } = useNativeClientEvents(enabled); - - useEffect(() => { - if (!clerkInstance) { - return; - } - - return registerNativeToJsSyncHandler(async event => { - if (!isMountedRef.current) { - throw new Error('ClerkProvider was unmounted before native client synchronization completed.'); - } - - await syncNativeClientToJs({ - clerkInstance, - nativeRefreshFromJsControllerRef, - nativeClientEvent: event, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - }); - }, [ - clerkInstance, - isMountedRef, - nativeRefreshFromJsControllerRef, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, - ]); - - useEffect(() => { - if (!enabled || !nativeClientEvent || nativeClientEvent.sourceId?.startsWith(nativeClientSyncSourceIdPrefix)) { - return; - } - - const syncNativeClientStateToJs = async () => { - try { - await synchronizeNativeClientToJs(nativeClientEvent); - } catch (error) { - console.error(`[ClerkProvider] Failed to sync native client state:`, error); - } - }; - - void syncNativeClientStateToJs(); - }, [enabled, nativeClientEvent]); -} diff --git a/packages/expo/src/provider/nativeClientSyncCoordinator.ts b/packages/expo/src/provider/nativeClientSyncCoordinator.ts deleted file mode 100644 index aa7de3b47fa..00000000000 --- a/packages/expo/src/provider/nativeClientSyncCoordinator.ts +++ /dev/null @@ -1,195 +0,0 @@ -import type { NativeClientEvent } from '../hooks/useNativeClientEvents'; - -type NativeToJsSyncHandler = (nativeClientEvent?: NativeClientEvent | null) => Promise; - -type NativeToJsSyncRegistration = { - handler: NativeToJsSyncHandler; - pendingEventSyncs: Set>; - pendingExplicitSync: Promise | null; - pendingExplicitSyncEvent: NativeClientEvent | null; - explicitSyncRequestGeneration: number; - explicitSyncCompletedGeneration: number; -}; - -const pendingJsToNativeSyncs = new Map, number>(); -const defaultPendingJsToNativeSyncTimeoutMs = 5_000; -let jsToNativeSyncGeneration = 0; -let latestSettledJsToNativeSyncGeneration = 0; -let latestJsToNativeSyncFailure: { error: unknown; generation: number } | null = null; -let nativeToJsSyncRegistration: NativeToJsSyncRegistration | null = null; -let jsToNativeSyncEpoch = 0; - -function removePendingSync(pendingSyncs: Set>, sync: Promise): void { - pendingSyncs.delete(sync); -} - -function mergeNativeClientEvents(current: NativeClientEvent | null, next: NativeClientEvent): NativeClientEvent { - if (!current) { - return next; - } - - return { - ...next, - changed: { - client: current.changed.client || next.changed.client, - deviceToken: current.changed.deviceToken || next.changed.deviceToken, - }, - }; -} - -function createPendingJsToNativeSyncTimeoutError(): Error & { code: 'environment_unavailable' } { - return Object.assign(new Error('Timed out waiting for the native Clerk client to synchronize.'), { - code: 'environment_unavailable' as const, - }); -} - -export function trackPendingJsToNativeSync( - sync: Promise, - timeoutMs = defaultPendingJsToNativeSyncTimeoutMs, -): () => void { - const epoch = jsToNativeSyncEpoch; - const generation = ++jsToNativeSyncGeneration; - let isInvalidated = false; - const trackedSync = sync.then( - () => { - if (!isInvalidated && epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) { - latestSettledJsToNativeSyncGeneration = generation; - latestJsToNativeSyncFailure = null; - } - }, - error => { - if (!isInvalidated && epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) { - latestSettledJsToNativeSyncGeneration = generation; - latestJsToNativeSyncFailure = { error, generation }; - } - }, - ); - - pendingJsToNativeSyncs.set(trackedSync, timeoutMs); - void trackedSync.then(() => pendingJsToNativeSyncs.delete(trackedSync)); - - return () => { - isInvalidated = true; - pendingJsToNativeSyncs.delete(trackedSync); - }; -} - -export async function waitForPendingJsToNativeSync(): Promise { - const waitStartedAt = Date.now(); - let deadline = waitStartedAt + defaultPendingJsToNativeSyncTimeoutMs; - while (pendingJsToNativeSyncs.size > 0) { - deadline = Math.max(deadline, waitStartedAt + Math.max(...pendingJsToNativeSyncs.values())); - const remainingMs = deadline - Date.now(); - if (remainingMs <= 0) { - throw createPendingJsToNativeSyncTimeoutError(); - } - - let timeoutId: ReturnType | undefined; - const timeout = new Promise((_resolve, reject) => { - timeoutId = setTimeout(() => { - reject(createPendingJsToNativeSyncTimeoutError()); - }, remainingMs); - }); - - try { - await Promise.race([Promise.all(pendingJsToNativeSyncs.keys()), timeout]); - } finally { - clearTimeout(timeoutId); - } - } - - if (latestJsToNativeSyncFailure) { - throw latestJsToNativeSyncFailure.error; - } -} - -export function __internal_resetNativeClientSyncCoordinator(): void { - jsToNativeSyncEpoch += 1; - pendingJsToNativeSyncs.clear(); - jsToNativeSyncGeneration = 0; - latestSettledJsToNativeSyncGeneration = 0; - latestJsToNativeSyncFailure = null; - nativeToJsSyncRegistration = null; -} - -export function registerNativeToJsSyncHandler(handler: NativeToJsSyncHandler): () => void { - const registration = { - handler, - pendingEventSyncs: new Set>(), - pendingExplicitSync: null, - pendingExplicitSyncEvent: null, - explicitSyncRequestGeneration: 0, - explicitSyncCompletedGeneration: 0, - }; - nativeToJsSyncRegistration = registration; - - return () => { - if (nativeToJsSyncRegistration === registration) { - nativeToJsSyncRegistration = null; - } - }; -} - -export function synchronizeNativeClientToJs(nativeClientEvent?: NativeClientEvent | null): Promise { - const registration = nativeToJsSyncRegistration; - if (!registration) { - return Promise.reject(new Error('Native Clerk client synchronization is not available.')); - } - - if (nativeClientEvent) { - if (registration.pendingExplicitSync) { - registration.pendingExplicitSyncEvent = mergeNativeClientEvents( - registration.pendingExplicitSyncEvent, - nativeClientEvent, - ); - registration.explicitSyncRequestGeneration += 1; - return registration.pendingExplicitSync; - } - - const pendingEvents = [...registration.pendingEventSyncs]; - const sync = Promise.all(pendingEvents.map(pendingEvent => pendingEvent.catch(() => undefined))).then(() => - registration.handler(nativeClientEvent), - ); - registration.pendingEventSyncs.add(sync); - void sync.then( - () => removePendingSync(registration.pendingEventSyncs, sync), - () => removePendingSync(registration.pendingEventSyncs, sync), - ); - return sync; - } - - registration.explicitSyncRequestGeneration += 1; - if (registration.pendingExplicitSync) { - return registration.pendingExplicitSync; - } - - const pendingEvents = [...registration.pendingEventSyncs]; - const sync = (async () => { - await Promise.all(pendingEvents.map(pendingEvent => pendingEvent.catch(() => undefined))); - - let firstError: unknown; - let didFail = false; - while (registration.explicitSyncCompletedGeneration < registration.explicitSyncRequestGeneration) { - const generation = registration.explicitSyncRequestGeneration; - const pendingEvent = registration.pendingExplicitSyncEvent; - registration.pendingExplicitSyncEvent = null; - try { - await (pendingEvent ? registration.handler(pendingEvent) : registration.handler()); - } catch (error) { - if (!didFail) { - firstError = error; - didFail = true; - } - } - registration.explicitSyncCompletedGeneration = generation; - } - - registration.pendingExplicitSync = null; - - if (didFail) { - throw firstError; - } - })(); - registration.pendingExplicitSync = sync; - return sync; -} diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index 4f08c16bedd..3c45a2d4059 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -453,6 +453,33 @@ describe('createClerkInstance', () => { expect(latestTokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'fresh-token'); }); + test('writes a rotated client token against the token each request was sent with', async () => { + const tokenCache = { + getToken: vi.fn().mockResolvedValueOnce('token-a').mockResolvedValueOnce('token-b'), + saveToken: vi.fn(() => Promise.resolve()), + saveClientToken: vi.fn(() => Promise.resolve()), + }; + + const createClerkInstance = await loadCreateClerkInstance(); + const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); + const clerk = getClerkInstance({ publishableKey: 'pk_test_123', tokenCache }) as unknown as MockClerk; + + const beforeRequest = clerk.__internal_onBeforeRequest.mock.calls[0][0]; + const afterResponse = clerk.__internal_onAfterResponse.mock.calls[0][0]; + const firstRequest = { headers: new Headers(), url: new URL('https://clerk.example.com/v1/client') }; + const secondRequest = { headers: new Headers(), url: new URL('https://clerk.example.com/v1/me') }; + await beforeRequest(firstRequest); + await beforeRequest(secondRequest); + await afterResponse(secondRequest, { headers: new Headers({ authorization: 'token-c' }), payload: null }); + await afterResponse(firstRequest, { headers: new Headers({ authorization: 'token-d' }), payload: null }); + + expect(tokenCache.saveClientToken.mock.calls).toEqual([ + ['token-c', 'token-b'], + ['token-d', 'token-a'], + ]); + expect(tokenCache.saveToken).not.toHaveBeenCalled(); + }); + describe('initial resource recovery', () => { const setupUnavailableResources = async () => { const createClerkInstance = await loadCreateClerkInstance(); diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index 56230604852..b82c8e64278 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -25,6 +25,7 @@ import { CLERK_CLIENT_JWT_KEY } from '../../constants'; import { errorThrower } from '../../errorThrower'; import { assertValidProxyUrl } from '../../utils/errors'; import { isNative } from '../../utils/runtime'; +import type { ClientTokenCache } from '../nativeClientSync'; import type { BuildClerkOptions } from './types'; /** @@ -124,7 +125,13 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { } const getToken = (key: string) => __internal_tokenCache.getToken(key); - const saveToken = (key: string, token: string) => __internal_tokenCache.saveToken(key, token); + const saveClientToken = (token: string, requestToken: string | null) => { + const tokenCache = __internal_tokenCache as TokenCache & Partial; + return tokenCache.saveClientToken + ? tokenCache.saveClientToken(token, requestToken) + : tokenCache.saveToken(CLERK_CLIENT_JWT_KEY, token); + }; + const requestTokens = new WeakMap(); __internal_clerkOptions = { publishableKey, proxyUrl, domain }; const clerk = new ClerkClass(publishableKey, { proxyUrl, domain }) as unknown as BrowserClerk; @@ -291,6 +298,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { requestInit.url?.searchParams.append('_is_native', '1'); const jwt = await getToken(CLERK_CLIENT_JWT_KEY); + requestTokens.set(requestInit, jwt ?? null); (requestInit.headers as Headers).set('authorization', jwt || ''); // Instructs the backend that the request is from a mobile device. @@ -303,10 +311,10 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { let nativeApiErrorShown = false; // @ts-expect-error - This is an internal API - __internal_clerk.__internal_onAfterResponse(async (_: FapiRequestInit, response: FapiResponse) => { + __internal_clerk.__internal_onAfterResponse(async (requestInit: FapiRequestInit, response: FapiResponse) => { const authHeader = response.headers.get('authorization'); if (authHeader) { - await saveToken(CLERK_CLIENT_JWT_KEY, authHeader); + await saveClientToken(authHeader, requestTokens.get(requestInit) ?? null); } if (__DEV__ && !nativeApiErrorShown && response.payload?.errors?.[0]?.code === 'native_api_disabled') { diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index 51d214fc53c..6870d597df8 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -6,18 +6,10 @@ import type { NativeClientSyncModule, } from './NativeClerkModule.types'; -interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, Partial { - // Exposed by Expo Modules EventEmitter for internal native client change events. +interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, NativeClientSyncModule { + // Exposed by Expo Modules EventEmitter for the internal `clerkNativeClientInvalidated` event. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; - configure(publishableKey: string, bearerToken: string | null): Promise; - getClientToken(): Promise; - syncClientStateFromJs( - deviceToken: string | null, - sourceId: string | null, - didChangeClient: boolean, - didChangeDeviceToken: boolean, - ): Promise; } // Optional so it resolves to null in Expo Go instead of throwing at import time. diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index c999c57d6c4..457fa431210 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -6,18 +6,10 @@ import type { NativeClientSyncModule, } from './NativeClerkModule.types'; -export interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, Partial { - // Exposed by Expo Modules EventEmitter for internal native client change events. +export interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, NativeClientSyncModule { + // Exposed by Expo Modules EventEmitter for the internal `clerkNativeClientInvalidated` event. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; - configure(publishableKey: string, bearerToken: string | null): Promise; - getClientToken(): Promise; - syncClientStateFromJs( - deviceToken: string | null, - sourceId: string | null, - didChangeClient: boolean, - didChangeDeviceToken: boolean, - ): Promise; } export default requireOptionalNativeModule('ClerkExpo'); diff --git a/packages/expo/src/utils/__tests__/native-module.test.ts b/packages/expo/src/utils/__tests__/native-module.test.ts index 70d95547ba5..cfe3882cdf7 100644 --- a/packages/expo/src/utils/__tests__/native-module.test.ts +++ b/packages/expo/src/utils/__tests__/native-module.test.ts @@ -10,9 +10,10 @@ const makeNativeModule = ({ includeEventMethods = true } = {}) => ({ addListener: vi.fn(), } : {}), - configure: vi.fn(), - getClientToken: vi.fn(), - syncClientStateFromJs: vi.fn(), + configureNative: vi.fn(), + getDeviceToken: vi.fn(), + setDeviceToken: vi.fn(), + refreshClient: vi.fn(), }); vi.mock('react-native', () => ({ @@ -54,7 +55,7 @@ describe('native module loader', () => { test('returns null when no native module satisfies the bootstrap contract', async () => { mocks.nativeModule = { - configure: vi.fn(), + configureNative: vi.fn(), }; const { ClerkExpoModule } = await importNativeModule(); diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index e81c3aabee9..e89da01bc8e 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -11,15 +11,8 @@ export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'andro export type ClerkExpoNativeModule = { addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; - configure(publishableKey: string, bearerToken: string | null): Promise; - getClientToken(): Promise; - syncClientStateFromJs( - deviceToken: string | null, - sourceId: string | null, - didChangeClient: boolean, - didChangeDeviceToken: boolean, - ): Promise; -} & Partial; +} & NativeClientSyncModule & + Partial; function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule { if (!module || typeof module !== 'object') { @@ -28,9 +21,10 @@ function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule { const maybeModule = module as Record; return ( - typeof maybeModule.configure === 'function' && - typeof maybeModule.getClientToken === 'function' && - typeof maybeModule.syncClientStateFromJs === 'function' + typeof maybeModule.configureNative === 'function' && + typeof maybeModule.getDeviceToken === 'function' && + typeof maybeModule.setDeviceToken === 'function' && + typeof maybeModule.refreshClient === 'function' ); } From 6949318b14794210fc0e530a6d890f6d1320c938 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:40:50 -0400 Subject: [PATCH 2/2] fix(expo): ignore session activity in the native sync fingerprint Session updatedAt can move on session activity and token refresh, which would refresh the native client on every token refresh. Profile edits still reach native through the active user's updatedAt. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../nativeClientSyncBehavior/fakeClerk.ts | 9 ++++++--- .../nativeClientSyncBehavior/fakeServer.ts | 13 ++++++++---- .../nativeClientSync.behavior.test.tsx | 20 +++++++++++++++++++ .../expo/src/provider/nativeClientSync.ts | 2 +- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts index 8358f2e7d43..0f7bfc91839 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts @@ -6,6 +6,7 @@ import type { FakeClerkServer, FakeServerClientJSON, FapiResponse } from './fake export type FakeSessionResource = { id: string; status: 'active'; + activityVersion: number; updatedAt: Date; user: { id: string; profileVersion: number; updatedAt: Date }; }; @@ -19,12 +20,12 @@ type ListenerCallback = (resources: { client: FakeClientResource; session: FakeS type StatusListener = (status: string) => void; function toSessionResource(session: FakeServerClientJSON['sessions'][number]): FakeSessionResource { - const updatedAt = new Date(session.profileVersion); return { id: session.id, status: 'active', - updatedAt, - user: { id: session.userId, profileVersion: session.profileVersion, updatedAt }, + activityVersion: session.activityVersion, + updatedAt: new Date(session.profileVersion + session.activityVersion), + user: { id: session.userId, profileVersion: session.profileVersion, updatedAt: new Date(session.profileVersion) }, }; } @@ -63,6 +64,7 @@ export class FakeClientResource { id: session.id, userId: session.user.id, profileVersion: session.user.profileVersion, + activityVersion: session.activityVersion, })), }; } @@ -238,6 +240,7 @@ export class FakeClerk { await this.handleUnauthenticated(); return false; } + this.#applyPiggybackedClient(response); return true; } diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts index 637a7d84d51..9a3ea40a8f8 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts @@ -2,6 +2,8 @@ export type FakeServerSessionJSON = { id: string; userId: string; profileVersion: number; + /** Bumped by session activity such as minting a session token. */ + activityVersion: number; }; export type FakeServerClientJSON = { @@ -95,7 +97,7 @@ export class FakeClerkServer { const { client, token } = this.#createClient(); const sessionIds: Record = {}; for (const userId of users) { - const session = { id: this.#nextId('sess'), userId, profileVersion: 0 }; + const session = { id: this.#nextId('sess'), userId, profileVersion: 0, activityVersion: 0 }; client.sessions.push(session); sessionIds[userId] = session.id; } @@ -151,7 +153,7 @@ export class FakeClerkServer { client = created.client; responseToken = created.token; } - const session = { id: this.#nextId('sess'), userId, profileVersion: 0 }; + const session = { id: this.#nextId('sess'), userId, profileVersion: 0, activityVersion: 0 }; client.sessions.push(session); client.lastActiveSessionId = session.id; return { ...this.#respond(responseToken, client), sessionId: session.id }; @@ -193,8 +195,11 @@ export class FakeClerkServer { async createSessionToken(caller: FapiCaller, token: string | null, sessionId: string): Promise { await this.#request(caller, 'POST /client/sessions/:id/tokens', token); const client = this.#clientFor(token); - const status = client?.sessions.some(session => session.id === sessionId) ? 200 : 401; - return this.#respond(token, client, status); + const session = client?.sessions.find(candidate => candidate.id === sessionId); + if (session) { + session.activityVersion += 1; + } + return this.#respond(token, client, session ? 200 : 401); } async updateUser(caller: FapiCaller, token: string | null, sessionId: string): Promise { diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx index 9a8030be429..8659c489ee8 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx @@ -289,6 +289,26 @@ describe('native client sync behavior', () => { await expectConverged(h); }); + it('refreshes native for a profile update but not for session activity that only bumps the session', async () => { + const { server, token, sessionIds } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + const refreshesBefore = h.native.clientRefreshCount; + const updatedAtBefore = h.js.session?.updatedAt.getTime(); + + await h.run(() => Promise.all([h.js.getSessionToken(), h.js.getSessionToken()])); + await h.settle(); + + expect(h.js.session?.updatedAt.getTime()).toBeGreaterThan(updatedAtBefore ?? Infinity); + expect(h.native.clientRefreshCount - refreshesBefore).toBe(0); + + await h.run(() => h.js.updateProfile()); + await h.settle(); + + expect(h.native.clientRefreshCount - refreshesBefore).toBe(1); + expect(h.native.client?.sessions.find(session => session.id === sessionIds.user_1)?.profileVersion).toBe(1); + await expectConverged(h); + }); + it('settles a JS change without an echo loop', async () => { const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1'); const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); diff --git a/packages/expo/src/provider/nativeClientSync.ts b/packages/expo/src/provider/nativeClientSync.ts index 6920d6afded..dc282fc4f3d 100644 --- a/packages/expo/src/provider/nativeClientSync.ts +++ b/packages/expo/src/provider/nativeClientSync.ts @@ -97,7 +97,7 @@ function fingerprint(clerk: SyncableClerk): string { return JSON.stringify([ client?.id ?? null, client?.lastActiveSessionId ?? null, - client?.signedInSessions.map(session => [session.id, session.status, +session.updatedAt]) ?? [], + client?.signedInSessions.map(session => [session.id, session.status]) ?? [], user?.id ?? null, +(user?.updatedAt ?? 0), ]);