From 245258ee137581db4c7e68098afb02ff1b5308b9 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:28:59 -0400 Subject: [PATCH] fix(expo): deduplicate native client startup requests --- .changeset/calm-clients-start.md | 5 + .../expo/modules/clerk/ClerkExpoModule.kt | 42 +++- packages/expo/ios/ClerkNativeBridge.swift | 80 ++++-- .../__tests__/useNativeClientEvents.test.ts | 16 ++ .../expo/src/hooks/useNativeClientEvents.ts | 9 +- packages/expo/src/provider/ClerkProvider.tsx | 5 +- .../ClerkProvider.nativeClientSync.test.tsx | 185 +++++++++++++- .../expo/src/provider/nativeClientSync.tsx | 231 ++++++++++++++---- 8 files changed, 480 insertions(+), 93 deletions(-) create mode 100644 .changeset/calm-clients-start.md diff --git a/.changeset/calm-clients-start.md b/.changeset/calm-clients-start.md new file mode 100644 index 00000000000..c4d448f56e8 --- /dev/null +++ b/.changeset/calm-clients-start.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Reduce redundant native and JavaScript client refreshes during Expo startup. diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index e60da738c95..c5718ade6ef 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -215,13 +215,15 @@ class ClerkExpoModule : Module() { coroutineScope.launch { try { + val normalizedBearerToken = bearerToken?.trim()?.takeIf { it.isNotEmpty() } + if (!Clerk.isInitialized.value) { // First-time initialization — write the bearer token to SharedPreferences // before initializing so the SDK boots with the correct client. - if (!bearerToken.isNullOrEmpty()) { + if (normalizedBearerToken != null) { context.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) .edit() - .putString("DEVICE_TOKEN", bearerToken) + .putString("DEVICE_TOKEN", normalizedBearerToken) .apply() } @@ -248,7 +250,7 @@ class ClerkExpoModule : Module() { } // If a bearer token was provided, wait for native client state to hydrate // before resolving the configure call. - if (!bearerToken.isNullOrEmpty()) { + if (normalizedBearerToken != null) { withTimeout(5_000L) { Clerk.clientFlow.first { it != null } } @@ -270,6 +272,7 @@ class ClerkExpoModule : Module() { promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}", null) } else { configuredPublishableKey = pubKey + lastObservedClientState = clientStateSnapshot() promise.resolve(null) } return@launch @@ -303,10 +306,13 @@ class ClerkExpoModule : Module() { return@launch } - if (!bearerToken.isNullOrEmpty()) { - val result = Clerk.updateDeviceToken(bearerToken) - if (result is ClerkResult.Failure) { - debugLog(TAG, "configure - updateDeviceToken after reconfigure failed: ${result.error}") + if (normalizedBearerToken != null) { + val clientState = clientStateSnapshot() + if (clientState.deviceToken != normalizedBearerToken || clientState.client == null) { + val result = Clerk.updateDeviceToken(normalizedBearerToken) + if (result is ClerkResult.Failure) { + debugLog(TAG, "configure - updateDeviceToken after reconfigure failed: ${result.error}") + } } try { @@ -319,6 +325,7 @@ class ClerkExpoModule : Module() { } configuredPublishableKey = pubKey + lastObservedClientState = clientStateSnapshot() promise.resolve(null) return@launch } @@ -326,10 +333,20 @@ class ClerkExpoModule : Module() { // Already initialized — use the public SDK API to update // the device token and trigger a client/environment refresh. startClientStateObserver() - if (!bearerToken.isNullOrEmpty()) { - val result = Clerk.updateDeviceToken(bearerToken) + if (normalizedBearerToken != null) { + val clientState = clientStateSnapshot() + val result = if ( + clientState.deviceToken != normalizedBearerToken || + clientState.client == null + ) { + Clerk.updateDeviceToken(normalizedBearerToken) + } else { + // A remounted JS runtime can have the same token while native + // client state is stale, so preserve one refresh in that case. + Clerk.refreshClient() + } if (result is ClerkResult.Failure) { - debugLog(TAG, "configure - updateDeviceToken failed: ${result.error}") + debugLog(TAG, "configure - client refresh failed: ${result.error}") } // Wait for client state to hydrate with the new token (up to 5s). @@ -342,6 +359,7 @@ class ClerkExpoModule : Module() { } } + lastObservedClientState = clientStateSnapshot() promise.resolve(null) } catch (e: Exception) { promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e) @@ -382,6 +400,7 @@ class ClerkExpoModule : Module() { try { jsOriginatedClientSyncDepth += 1 val previousClientState = clientStateSnapshot() + var refreshedClientWhileUpdatingToken = false if (didChangeDeviceToken && !deviceToken.isNullOrBlank()) { val currentDeviceToken = try { @@ -401,6 +420,7 @@ class ClerkExpoModule : Module() { return@launch } is ClerkResult.Success -> { + refreshedClientWhileUpdatingToken = true try { withTimeout(5_000L) { Clerk.clientFlow.first { it != null } @@ -413,7 +433,7 @@ class ClerkExpoModule : Module() { } } - if (didChangeClient || didChangeDeviceToken) { + if (!refreshedClientWhileUpdatingToken && (didChangeClient || didChangeDeviceToken)) { when (val result = Clerk.refreshClient()) { is ClerkResult.Failure -> { promise.reject( diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index fcbf1c9ecdd..bc46e7074af 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -48,6 +48,8 @@ final class ClerkNativeBridge { private var clientObservationGeneration = 0 private var lastObservedClientState: ClientStateSnapshot? + private var configurationDepth = 0 + private var jsOriginatedClientSyncDepth = 0 private init() {} @@ -74,6 +76,12 @@ final class ClerkNativeBridge { @MainActor func configure(publishableKey: String, bearerToken: String? = nil) async throws { + configurationDepth += 1 + defer { + lastObservedClientState = Self.clerkConfigured ? Self.clientStateSnapshot() : nil + configurationDepth = max(0, configurationDepth - 1) + } + loadThemes() if Self.shouldReconfigure(for: publishableKey) { @@ -84,16 +92,21 @@ final class ClerkNativeBridge { let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) await Self.waitForLoadedClientIfNeeded(shouldWaitForClient) - Self.emitClientChangedIfReceivedToken(bearerToken) Self.postConfiguredNotification() return } if Self.clerkConfigured { startClientObserver() - let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) - await Self.waitForLoadedClientIfNeeded(shouldWaitForClient) - Self.emitClientChangedIfReceivedToken(bearerToken) + let didUpdateDeviceToken = try await Self.syncTokenState(bearerToken: bearerToken) + if didUpdateDeviceToken { + await Self.waitForLoadedClient() + } else if let token = bearerToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { + // A remounted JS runtime can have the same token while native client + // state is stale, so preserve one refresh in that case. + _ = try await Clerk.shared.refreshClient() + await Self.waitForLoadedClient() + } return } @@ -104,16 +117,9 @@ final class ClerkNativeBridge { let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) await Self.waitForLoadedClientIfNeeded(shouldWaitForClient) - Self.emitClientChangedIfReceivedToken(bearerToken) Self.postConfiguredNotification() } - @MainActor - private static func emitClientChangedIfReceivedToken(_ bearerToken: String?) { - guard let token = bearerToken, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - Self.emitClientChanged(Self.clientChangedPayload(changes: .init(client: false, deviceToken: true))) - } - @MainActor private func startClientObserver(reset: Bool = false) { guard reset || clientObservationGeneration == 0 else { @@ -139,13 +145,15 @@ final class ClerkNativeBridge { let newClientState = Self.clientStateSnapshot() if let previousClientState = self.lastObservedClientState, newClientState != previousClientState { self.lastObservedClientState = newClientState - let payload = Self.clientChangedPayload( - changes: .init( - client: newClientState.client != previousClientState.client, - deviceToken: newClientState.deviceToken != previousClientState.deviceToken + if self.configurationDepth == 0, self.jsOriginatedClientSyncDepth == 0 { + let payload = Self.clientChangedPayload( + changes: .init( + client: newClientState.client != previousClientState.client, + deviceToken: newClientState.deviceToken != previousClientState.deviceToken + ) ) - ) - Self.emitClientChanged(payload) + Self.emitClientChanged(payload) + } } self.observeClient(generation: generation) @@ -180,8 +188,14 @@ final class ClerkNativeBridge { @MainActor private static func syncTokenState(bearerToken: String?) async throws -> Bool { - guard let token = bearerToken, !token.isEmpty else { - return Clerk.shared.deviceToken != nil + await waitForLoadedClient() + + guard let token = bearerToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty + else { + return false + } + guard Clerk.shared.deviceToken != token || Clerk.shared.client == nil else { + return false } _ = try await Clerk.shared.updateDeviceToken(token) return true @@ -281,15 +295,38 @@ final class ClerkNativeBridge { guard Self.clerkConfigured else { return } let previousClientState = Self.clientStateSnapshot() + var completedSuccessfully = false + jsOriginatedClientSyncDepth += 1 + defer { + let finalClientState = Self.clientStateSnapshot() + lastObservedClientState = finalClientState + jsOriginatedClientSyncDepth = max(0, jsOriginatedClientSyncDepth - 1) + + if !completedSuccessfully, finalClientState != previousClientState { + Self.emitClientChanged( + Self.clientChangedPayload( + changes: .init( + client: finalClientState.client != previousClientState.client, + deviceToken: finalClientState.deviceToken != previousClientState.deviceToken + ) + ) + ) + } + } + + var refreshedClientWhileUpdatingToken = false - if didChangeDeviceToken, let token = deviceToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { + if didChangeDeviceToken, + let token = deviceToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty + { if Clerk.shared.deviceToken != token { _ = try await Clerk.shared.updateDeviceToken(token) await Self.waitForLoadedClient() + refreshedClientWhileUpdatingToken = true } } - if didChangeClient || didChangeDeviceToken { + if !refreshedClientWhileUpdatingToken, didChangeClient || didChangeDeviceToken { _ = try await Clerk.shared.refreshClient() await Self.waitForLoadedClient() } @@ -305,6 +342,7 @@ final class ClerkNativeBridge { ) ) ) + completedSuccessfully = true } private static func postConfiguredNotification() { diff --git a/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts b/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts index c0294ff52d7..fb8eaba5cae 100644 --- a/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts +++ b/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts @@ -67,6 +67,22 @@ describe('useNativeClientEvents', () => { 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(), diff --git a/packages/expo/src/hooks/useNativeClientEvents.ts b/packages/expo/src/hooks/useNativeClientEvents.ts index 5d46e62958e..a48a2ee316c 100644 --- a/packages/expo/src/hooks/useNativeClientEvents.ts +++ b/packages/expo/src/hooks/useNativeClientEvents.ts @@ -54,10 +54,15 @@ function isNativeClientSnapshot(snapshot: NativeClientSnapshot | undefined): sna /** * Listens for native client events that should sync JS client state. */ -export function useNativeClientEvents(): UseNativeClientEventsReturn { +export function useNativeClientEvents(enabled = true): UseNativeClientEventsReturn { const [nativeClientEvent, setNativeClientEvent] = useState(null); useEffect(() => { + if (!enabled) { + setNativeClientEvent(null); + return; + } + if (!isNativeSupported || !ClerkExpo) { return; } @@ -87,7 +92,7 @@ export function useNativeClientEvents(): UseNativeClientEventsReturn { return () => { subscription?.remove(); }; - }, []); + }, [enabled]); return { nativeClientEvent, diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 24e53636a3e..127600b48a9 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -90,13 +90,15 @@ export function ClerkProvider(props: ClerkProviderProps(props: ClerkProviderProps {isNative() && ( { 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, }; }); @@ -135,9 +139,12 @@ describe('ClerkProvider native client sync', () => { mocks.clerkInstance.addOnLoaded = vi.fn(); mocks.clerkInstance.client = undefined; mocks.clerkInstance.handleUnauthenticated = vi.fn().mockResolvedValue(undefined); - mocks.clerkInstance.loaded = false; + 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; @@ -148,6 +155,7 @@ describe('ClerkProvider native client sync', () => { }); mocks.clerkListener = undefined; mocks.clerkOnLoaded = undefined; + mocks.clerkStatusListener = undefined; mocks.clerkInstance.addOnLoaded.mockImplementation(listener => { mocks.clerkOnLoaded = listener; }); @@ -155,24 +163,37 @@ describe('ClerkProvider native client sync', () => { mocks.clerkListener = listener; return vi.fn(); }); + mocks.clerkInstance.on.mockImplementation((event, listener) => { + if (event === 'status') { + mocks.clerkStatusListener = listener; + } + }); }); - test('configures native with the cached device token during bootstrap', async () => { + test('configures native once with the cached device token during StrictMode bootstrap', async () => { mocks.tokenCache.getToken.mockResolvedValue('client-token'); + mocks.getClientToken.mockResolvedValue('client-token'); render( - , + + + , ); await waitFor(() => { expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); }); + expect(mocks.configure).toHaveBeenCalledTimes(1); + expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); + expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); }); 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( @@ -183,21 +204,164 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.clerkInstance.addOnLoaded).toHaveBeenCalled(); + 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.clerkOnLoaded?.(); + 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('keeps synchronization enabled when native configure rejects', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + mocks.configure.mockRejectedValue(new Error('native refresh failed')); + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledTimes(1); + }); + + act(() => { + mocks.clerkListener?.(); + }); + + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false); + }); + + 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 () => { @@ -1114,6 +1278,9 @@ describe('ClerkProvider native client sync', () => { }); 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.configure).toHaveBeenCalled(); + expect(mocks.clerkInstance.addListener).toHaveBeenCalled(); }); mocks.syncClientStateFromJs.mockClear(); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 1a24d566f07..1f50aa0699e 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -1,5 +1,5 @@ import type { ClientResource, SignedInSessionResource } from '@clerk/shared/types'; -import { type MutableRefObject, useCallback, useEffect, useMemo, useRef } from 'react'; +import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Platform } from 'react-native'; import { MemoryTokenCache } from '../cache'; @@ -19,7 +19,10 @@ export type SyncableClerkInstance = { 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_reloadInitialResources?: () => void | Promise; @@ -61,6 +64,9 @@ export function useSyncableTokenCache({ return undefined; } + let hasKnownDeviceToken = false; + let knownDeviceToken: string | null = null; + const notifyDeviceTokenListeners = (deviceToken: string | null) => { if (suppressTokenCacheNotificationsRef.current > 0) { return; @@ -72,17 +78,34 @@ export function useSyncableTokenCache({ }; return { - getToken: key => effectiveTokenCache.getToken(key), + 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) { - notifyDeviceTokenListeners(token); + 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) { - notifyDeviceTokenListeners(null); + const didChange = !hasKnownDeviceToken || knownDeviceToken !== null; + hasKnownDeviceToken = true; + knownDeviceToken = null; + if (didChange) { + notifyDeviceTokenListeners(null); + } } }, }; @@ -433,6 +456,7 @@ async function syncNativeClientToJs({ * resources to emit. */ export function NativeClientSync({ + enabled, clerkInstance, nativeRefreshFromJsControllerRef, suppressJsClientChangedRef, @@ -440,6 +464,7 @@ export function NativeClientSync({ tokenCache, tokenCacheListenersRef, }: { + enabled: boolean; clerkInstance: SyncableClerkInstance | null | undefined; nativeRefreshFromJsControllerRef: MutableRefObject; suppressJsClientChangedRef: MutableRefObject; @@ -449,10 +474,14 @@ export function NativeClientSync({ }): null { const isRefreshingNativeFromJsRef = useRef(false); const pendingNativeRefreshRef = useRef(null); + const pendingNativeRefreshBeforeReadyRef = useRef(null); const nativeRefreshGenerationRef = useRef(0); + const enabledRef = useRef(enabled); + enabledRef.current = enabled; const cancelNativeRefreshFromJs = useCallback(() => { pendingNativeRefreshRef.current = null; + pendingNativeRefreshBeforeReadyRef.current = null; nativeRefreshGenerationRef.current += 1; isRefreshingNativeFromJsRef.current = false; }, []); @@ -598,13 +627,38 @@ export function NativeClientSync({ }); }, []); + useEffect(() => { + if (!enabled) { + pendingNativeRefreshBeforeReadyRef.current = null; + return; + } + + if (pendingNativeRefreshBeforeReadyRef.current) { + const pendingOptions = pendingNativeRefreshBeforeReadyRef.current; + pendingNativeRefreshBeforeReadyRef.current = null; + queueNativeRefreshFromJs(pendingOptions); + } + }, [enabled, queueNativeRefreshFromJs]); + useEffect(() => { const listener: DeviceTokenCacheListener = deviceToken => { - queueNativeRefreshFromJs({ + const options = { deviceToken, didChangeClient: false, didChangeDeviceToken: true, - }); + }; + + if (!enabledRef.current) { + if (clerkInstance?.loaded) { + pendingNativeRefreshBeforeReadyRef.current = mergePendingNativeRefreshOptions( + pendingNativeRefreshBeforeReadyRef.current, + options, + ); + } + return; + } + + queueNativeRefreshFromJs(options); }; const tokenCacheListeners = tokenCacheListenersRef.current; @@ -612,7 +666,7 @@ export function NativeClientSync({ return () => { tokenCacheListeners.delete(listener); }; - }, [queueNativeRefreshFromJs, tokenCacheListenersRef]); + }, [clerkInstance, queueNativeRefreshFromJs, tokenCacheListenersRef]); useEffect(() => { if (!clerkInstance || typeof clerkInstance.handleUnauthenticated !== 'function') { @@ -684,6 +738,19 @@ export function NativeClientSync({ return; } + if (!enabledRef.current) { + if (clerkInstance.loaded) { + pendingNativeRefreshBeforeReadyRef.current = mergePendingNativeRefreshOptions( + pendingNativeRefreshBeforeReadyRef.current, + { + didChangeClient: true, + didChangeDeviceToken: false, + }, + ); + } + return; + } + queueNativeRefreshFromJs({ didChangeClient: true, didChangeDeviceToken: false, @@ -700,86 +767,142 @@ export function NativeClientSync({ 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({ publishableKey, + nativeRefreshFromJsControllerRef, suppressTokenCacheNotificationsRef, tokenCache, clerkInstance, }: { publishableKey: string; + nativeRefreshFromJsControllerRef: MutableRefObject; suppressTokenCacheNotificationsRef: MutableRefObject; tokenCache: TokenCache | undefined; clerkInstance: SyncableClerkInstance | null | undefined; }) { - const initStartedRef = useRef(false); - const nativeClientSyncedRef = useRef(false); + const startedPublishableKeyRef = useRef(null); const isMountedRef = useRef(true); - - useEffect(() => { - initStartedRef.current = false; - nativeClientSyncedRef.current = false; - }, [publishableKey]); + const [readyPublishableKey, setReadyPublishableKey] = useState(null); useEffect(() => { isMountedRef.current = true; - if ((Platform.OS === 'ios' || Platform.OS === 'android') && publishableKey && !initStartedRef.current) { - initStartedRef.current = true; + if ( + (Platform.OS === 'ios' || Platform.OS === 'android') && + publishableKey && + startedPublishableKeyRef.current !== publishableKey + ) { + startedPublishableKeyRef.current = publishableKey; + const configuringPublishableKey = publishableKey; + const isCurrentConfiguration = () => + isMountedRef.current && startedPublishableKeyRef.current === configuringPublishableKey; const configureNativeClerk = async () => { + let didAttemptConfigure = false; try { const ClerkExpo = NativeClerkModule; if (ClerkExpo?.configure) { - await ClerkExpo.configure(publishableKey, null); + if (clerkInstance) { + await waitForClerkInstanceLoad(clerkInstance); - if (!isMountedRef.current) { - return; + if (!isCurrentConfiguration()) { + return; + } } - let cachedDeviceToken: string | null = null; + let initialJsDeviceToken: string | null = null; try { - cachedDeviceToken = await getCachedDeviceToken(tokenCache); + initialJsDeviceToken = await getCachedDeviceToken(tokenCache); } catch (e) { if (__DEV__) { console.warn('[ClerkProvider] Token cache read failed:', e); } } - if (cachedDeviceToken) { - await ClerkExpo.configure(publishableKey, cachedDeviceToken); + if (!isCurrentConfiguration()) { + return; + } - if (!isMountedRef.current) { - return; - } + didAttemptConfigure = true; + await ClerkExpo.configure(configuringPublishableKey, initialJsDeviceToken); + + if (!isCurrentConfiguration()) { + return; } if (clerkInstance) { - const waitForLoad = (): Promise => { - return new Promise(resolve => { - if (clerkInstance.loaded) { - resolve(); - } else if (typeof clerkInstance.addOnLoaded === 'function') { - clerkInstance.addOnLoaded(() => resolve()); - } else { - if (__DEV__) { - console.warn('[ClerkProvider] Clerk instance has no loaded property or addOnLoaded method'); - } - resolve(); - } - }); - }; - - await waitForLoad(); + const currentJsDeviceToken = await getCachedDeviceToken(tokenCache); + const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); - if (!isMountedRef.current) { + if (!isCurrentConfiguration() || currentJsDeviceToken === nativeDeviceToken) { return; } - if (!nativeClientSyncedRef.current) { - nativeClientSyncedRef.current = true; + 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, }); @@ -798,6 +921,10 @@ export function useNativeClientBootstrap({ } else if (__DEV__) { console.error(`[ClerkProvider] Failed to configure Clerk ${Platform.OS}:`, error); } + } finally { + if (didAttemptConfigure && isCurrentConfiguration()) { + setReadyPublishableKey(configuringPublishableKey); + } } }; void configureNativeClerk(); @@ -806,12 +933,16 @@ export function useNativeClientBootstrap({ return () => { isMountedRef.current = false; }; - }, [publishableKey, suppressTokenCacheNotificationsRef, tokenCache, clerkInstance]); + }, [publishableKey, nativeRefreshFromJsControllerRef, suppressTokenCacheNotificationsRef, tokenCache, clerkInstance]); - return isMountedRef; + return { + isMountedRef, + isNativeClientReady: readyPublishableKey === publishableKey, + }; } export function useNativeClientEventSync({ + enabled, clerkInstance, isMountedRef, nativeRefreshFromJsControllerRef, @@ -819,6 +950,7 @@ export function useNativeClientEventSync({ suppressTokenCacheNotificationsRef, tokenCache, }: { + enabled: boolean; clerkInstance: SyncableClerkInstance | null | undefined; isMountedRef: MutableRefObject; nativeRefreshFromJsControllerRef: MutableRefObject; @@ -826,10 +958,10 @@ export function useNativeClientEventSync({ suppressTokenCacheNotificationsRef: MutableRefObject; tokenCache: TokenCache | undefined; }) { - const { nativeClientEvent } = useNativeClientEvents(); + const { nativeClientEvent } = useNativeClientEvents(enabled); useEffect(() => { - if (!nativeClientEvent || !clerkInstance) { + if (!enabled || !nativeClientEvent || !clerkInstance) { return; } @@ -857,6 +989,7 @@ export function useNativeClientEventSync({ void syncNativeClientStateToJs(); }, [ + enabled, nativeClientEvent, clerkInstance, isMountedRef,