diff --git a/.changeset/client-driven-native-sync.md b/.changeset/client-driven-native-sync.md new file mode 100644 index 00000000000..9ef01c5d16a --- /dev/null +++ b/.changeset/client-driven-native-sync.md @@ -0,0 +1,5 @@ +--- +"@clerk/expo": patch +--- + +Fix JS/native client syncing so native and JavaScript client or device-token changes refresh each other consistently. 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 175631e9fd5..13646049e73 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 @@ -35,16 +35,32 @@ private fun debugLog(tag: String, message: String) { class ClerkExpoModule : Module() { private val coroutineScope = CoroutineScope(Dispatchers.Main) private var clientStateObserverJob: Job? = null - private var lastObservedClient: Client? = null + private var lastObservedClientState: ClientStateSnapshot? = null private var jsOriginatedClientSyncDepth = 0 private var configuredPublishableKey: String? = null + private data class ClientStateSnapshot( + val client: Client?, + val deviceToken: String? + ) + + private data class ClientStateChanges( + val client: Boolean, + val deviceToken: Boolean + ) + companion object { private var sharedInstance: ClerkExpoModule? = null fun emitClientChanged(sourceId: String? = null) { val instance = sharedInstance ?: return - instance.sendEvent(NATIVE_CLIENT_CHANGED_EVENT, instance.clientChangedPayload(sourceId)) + instance.sendEvent( + NATIVE_CLIENT_CHANGED_EVENT, + instance.clientChangedPayload( + sourceId = sourceId, + changes = ClientStateChanges(client = true, deviceToken = true) + ) + ) } } @@ -73,11 +89,17 @@ class ClerkExpoModule : Module() { getClientToken(promise) } - AsyncFunction("syncFromJsClientToken") { clientToken: String?, sourceId: String?, shouldRefreshClient: Boolean?, promise: Promise -> - syncFromJsClientToken( - clientToken, + AsyncFunction("syncClientStateFromJs") { + deviceToken: String?, + sourceId: String?, + didChangeClient: Boolean, + didChangeDeviceToken: Boolean, + promise: Promise -> + syncClientStateFromJs( + deviceToken, sourceId, - shouldRefreshClient ?: clientToken.isNullOrBlank(), + didChangeClient, + didChangeDeviceToken, promise ) } @@ -91,42 +113,80 @@ class ClerkExpoModule : Module() { return } - lastObservedClient = Clerk.clientFlow.value + lastObservedClientState = clientStateSnapshot() clientStateObserverJob = coroutineScope.launch { Clerk.clientFlow.collect { client -> - if (client == lastObservedClient) { + val previousClientState = lastObservedClientState + val newClientState = clientStateSnapshot(client) + + if (newClientState == previousClientState) { return@collect } - lastObservedClient = client + lastObservedClientState = newClientState if (jsOriginatedClientSyncDepth > 0) { return@collect } - emitClientChanged() + sendEvent( + NATIVE_CLIENT_CHANGED_EVENT, + clientChangedPayload( + deviceToken = newClientState.deviceToken, + changes = ClientStateChanges( + client = newClientState.client != previousClientState?.client, + deviceToken = newClientState.deviceToken != previousClientState?.deviceToken + ) + ) + ) } } } - private fun clientChangedPayload(sourceId: String? = null): Map { - val result = mutableMapOf( - "clientToken" to try { + private fun clientStateSnapshot(client: Client? = Clerk.clientFlow.value): ClientStateSnapshot { + return ClientStateSnapshot( + client = client, + deviceToken = try { Clerk.getDeviceToken() } catch (e: Exception) { - debugLog(TAG, "clientChangedPayload - getDeviceToken failed: ${e.message}") + debugLog(TAG, "clientStateSnapshot - getDeviceToken failed: ${e.message}") null } ) + } + + private fun clientChangedPayload( + sourceId: String? = null, + changes: ClientStateChanges, + deviceToken: String? = clientStateSnapshot().deviceToken + ): Map { + val result = mutableMapOf( + "changed" to mapOf( + "client" to changes.client, + "deviceToken" to changes.deviceToken + ), + "deviceToken" to deviceToken + ) if (!sourceId.isNullOrEmpty()) { result["sourceId"] = sourceId } return result } - private fun emitSyncedClientChanged(sourceId: String?) { - lastObservedClient = Clerk.clientFlow.value - emitClientChanged(sourceId) + private fun emitSyncedClientChanged( + sourceId: String?, + changes: ClientStateChanges, + snapshot: ClientStateSnapshot = clientStateSnapshot() + ) { + lastObservedClientState = snapshot + sendEvent( + NATIVE_CLIENT_CHANGED_EVENT, + clientChangedPayload( + sourceId = sourceId, + changes = changes, + deviceToken = snapshot.deviceToken + ) + ) } // MARK: - configure @@ -288,12 +348,13 @@ class ClerkExpoModule : Module() { } } - // MARK: - syncFromJsClientToken + // MARK: - syncClientStateFromJs - private fun syncFromJsClientToken( - clientToken: String?, + private fun syncClientStateFromJs( + deviceToken: String?, sourceId: String?, - shouldRefreshClient: Boolean, + didChangeClient: Boolean, + didChangeDeviceToken: Boolean, promise: Promise ) { if (!Clerk.isInitialized.value) { @@ -304,25 +365,21 @@ class ClerkExpoModule : Module() { coroutineScope.launch { try { jsOriginatedClientSyncDepth += 1 - if (!clientToken.isNullOrBlank()) { + val previousClientState = clientStateSnapshot() + + if (didChangeDeviceToken && !deviceToken.isNullOrBlank()) { val currentDeviceToken = try { Clerk.getDeviceToken() } catch (_: Exception) { null } - if (currentDeviceToken == clientToken) { - if (!shouldRefreshClient) { - emitSyncedClientChanged(sourceId) - promise.resolve(null) - return@launch - } - } else { - when (val result = Clerk.updateDeviceToken(clientToken)) { + if (currentDeviceToken != deviceToken) { + when (val result = Clerk.updateDeviceToken(deviceToken)) { is ClerkResult.Failure -> { promise.reject( "E_SYNC_FROM_JS_FAILED", - result.error?.firstMessage() ?: result.throwable?.message ?: "Client token sync failed", + result.error?.firstMessage() ?: result.throwable?.message ?: "Device token sync failed", null ) return@launch @@ -333,19 +390,14 @@ class ClerkExpoModule : Module() { Clerk.clientFlow.first { it != null } } } catch (_: TimeoutCancellationException) { - debugLog(TAG, "syncFromJsClientToken - client did not appear after token update") - } - if (!shouldRefreshClient) { - emitSyncedClientChanged(sourceId) - promise.resolve(null) - return@launch + debugLog(TAG, "syncClientStateFromJs - client did not appear after token update") } } } } } - if (shouldRefreshClient) { + if (didChangeClient || didChangeDeviceToken) { when (val result = Clerk.refreshClient()) { is ClerkResult.Failure -> { promise.reject( @@ -355,17 +407,33 @@ class ClerkExpoModule : Module() { ) } is ClerkResult.Success -> { - emitSyncedClientChanged(sourceId) + val newClientState = clientStateSnapshot() + emitSyncedClientChanged( + sourceId, + ClientStateChanges( + client = newClientState.client != previousClientState.client, + deviceToken = newClientState.deviceToken != previousClientState.deviceToken + ), + newClientState + ) promise.resolve(null) } } return@launch } - emitSyncedClientChanged(sourceId) + val newClientState = clientStateSnapshot() + emitSyncedClientChanged( + sourceId, + ClientStateChanges( + client = newClientState.client != previousClientState.client, + deviceToken = newClientState.deviceToken != previousClientState.deviceToken + ), + newClientState + ) promise.resolve(null) } catch (e: Exception) { - promise.reject("E_SYNC_FROM_JS_FAILED", e.message ?: "Client token sync failed", e) + promise.reject("E_SYNC_FROM_JS_FAILED", e.message ?: "Client state sync failed", e) } finally { jsOriginatedClientSyncDepth = maxOf(0, jsOriginatedClientSyncDepth - 1) } diff --git a/packages/expo/ios/ClerkExpoModule.m b/packages/expo/ios/ClerkExpoModule.m index 6d74be037cf..2d12e4be649 100644 --- a/packages/expo/ios/ClerkExpoModule.m +++ b/packages/expo/ios/ClerkExpoModule.m @@ -11,9 +11,10 @@ @interface RCT_EXTERN_MODULE(ClerkExpo, RCTEventEmitter) RCT_EXTERN_METHOD(getClientToken:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(syncFromJsClientToken:(id)clientToken +RCT_EXTERN_METHOD(syncClientStateFromJs:(id)deviceToken sourceId:(id)sourceId - shouldRefreshClient:(id)shouldRefreshClient + didChangeClient:(BOOL)didChangeClient + didChangeDeviceToken:(BOOL)didChangeDeviceToken resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index 60016a7219e..c4a4658f36c 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -86,23 +86,23 @@ class ClerkExpoModule: RCTEventEmitter { } } - // MARK: - syncFromJsClientToken + // MARK: - syncClientStateFromJs - @objc func syncFromJsClientToken(_ clientToken: Any?, + @objc func syncClientStateFromJs(_ deviceToken: Any?, sourceId: Any?, - shouldRefreshClient: Any?, + didChangeClient: Bool, + didChangeDeviceToken: Bool, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - let normalizedClientToken = clientToken as? String + let normalizedDeviceToken = deviceToken as? String let normalizedSourceId = sourceId as? String - let defaultShouldRefreshClient = normalizedClientToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true - let normalizedShouldRefreshClient = (shouldRefreshClient as? Bool) ?? defaultShouldRefreshClient Task { do { - try await ClerkNativeBridge.shared.syncFromJsClientToken( - normalizedClientToken, + try await ClerkNativeBridge.shared.syncClientStateFromJs( + deviceToken: normalizedDeviceToken, sourceId: normalizedSourceId, - shouldRefreshClient: normalizedShouldRefreshClient + didChangeClient: didChangeClient, + didChangeDeviceToken: didChangeDeviceToken ) resolve(nil) } catch { diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 45707afa7c3..f668f141b11 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -56,6 +56,13 @@ final class ClerkNativeBridge { let deviceToken: String? } + private struct ClientStateChanges { + let client: Bool + let deviceToken: Bool + + static let all = ClientStateChanges(client: true, deviceToken: true) + } + /// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first /// (for extension apps sharing a keychain group), then falling back to the bundle identifier. private static var keychainService: String? { @@ -104,7 +111,7 @@ final class ClerkNativeBridge { @MainActor private static func emitClientChangedIfReceivedToken(_ bearerToken: String?) { guard let token = bearerToken, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - Self.emitClientChanged(Self.clientChangedPayload()) + Self.emitClientChanged(Self.clientChangedPayload(changes: .init(client: false, deviceToken: true))) } @MainActor @@ -130,9 +137,14 @@ final class ClerkNativeBridge { guard let self, generation == self.clientObservationGeneration else { return } let newClientState = Self.clientStateSnapshot() - if newClientState != self.lastObservedClientState { + if let previousClientState = self.lastObservedClientState, newClientState != previousClientState { self.lastObservedClientState = newClientState - let payload = Self.clientChangedPayload() + let payload = Self.clientChangedPayload( + changes: .init( + client: newClientState.client != previousClientState.client, + deviceToken: newClientState.deviceToken != previousClientState.deviceToken + ) + ) Self.emitClientChanged(payload) } @@ -152,9 +164,13 @@ final class ClerkNativeBridge { } @MainActor - private static func clientChangedPayload(sourceId: String? = nil) -> [String: Any] { + private static func clientChangedPayload(sourceId: String? = nil, changes: ClientStateChanges = .all) -> [String: Any] { var payload: [String: Any] = [:] - payload["clientToken"] = Clerk.shared.deviceToken ?? NSNull() + payload["changed"] = [ + "client": changes.client, + "deviceToken": changes.deviceToken, + ] + payload["deviceToken"] = Clerk.shared.deviceToken ?? NSNull() if let sourceId, !sourceId.isEmpty { payload["sourceId"] = sourceId } @@ -187,7 +203,7 @@ final class ClerkNativeBridge { @MainActor private static func waitForLoadedClient() async { // Wait for Clerk to finish loading client state from cached data + API refresh. - // The bridge sync contract is client-token based, not session based. + // The bridge sync contract is device-token based, not session based. for _ in 0.. { act(() => { mocks.nativeListener?.({ - clientToken: 'client-token', + changed: { + client: false, + deviceToken: true, + }, + deviceToken: 'device-token', sourceId: 'native-source', }); }); await waitFor(() => { - expect(result.current.nativeClientEvent?.clientToken).toBe('client-token'); + 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'); }); @@ -75,7 +83,7 @@ describe('useNativeClientEvents', () => { mocks.nativeModule = { configure: vi.fn(), getClientToken: vi.fn(), - syncFromJsClientToken: vi.fn(), + syncClientStateFromJs: vi.fn(), }; const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); diff --git a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts index 64039cdfb5c..36c38445505 100644 --- a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts +++ b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts @@ -51,7 +51,7 @@ vi.mock('../../specs/NativeClerkModule', () => { default: { configure: vi.fn(), getClientToken: vi.fn(), - syncFromJsClientToken: vi.fn(), + syncClientStateFromJs: vi.fn(), }, }; }); diff --git a/packages/expo/src/hooks/useNativeClientEvents.ts b/packages/expo/src/hooks/useNativeClientEvents.ts index e440a20f188..5d44611980b 100644 --- a/packages/expo/src/hooks/useNativeClientEvents.ts +++ b/packages/expo/src/hooks/useNativeClientEvents.ts @@ -6,7 +6,11 @@ import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native const nativeClientChangedEvent = 'clerkNativeClientChanged'; export interface NativeClientSnapshot { - clientToken?: string | null; + changed: { + client: boolean; + deviceToken: boolean; + }; + deviceToken: string | null; sourceId?: string | null; } @@ -44,6 +48,14 @@ function getNativeClientEventEmitter(): RefreshClientEventEmitter | null { 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. */ @@ -65,6 +77,10 @@ export function useNativeClientEvents(): UseNativeClientEventsReturn { } subscription = eventEmitter.addListener(nativeClientChangedEvent, snapshot => { + if (!isNativeClientSnapshot(snapshot)) { + return; + } + setNativeClientEvent({ issuedAt: Date.now(), ...snapshot }); }); } catch (error) { diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 5fe6a635626..24e53636a3e 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -8,7 +8,7 @@ import type { TokenCache } from '../cache/types'; import { isNative, isWeb } from '../utils/runtime'; import { maybeCompleteAuthSession } from './maybeCompleteAuthSession'; import { - type ClientTokenCacheListener, + type DeviceTokenCacheListener, NativeClientSync, type NativeRefreshFromJsController, useNativeClientBootstrap, @@ -69,8 +69,8 @@ export function ClerkProvider(props: ClerkProviderProps>(new Set()); - const suppressTokenCacheNotificationsRef = useRef(false); + const tokenCacheListenersRef = useRef>(new Set()); + const suppressTokenCacheNotificationsRef = useRef(0); const nativeRefreshFromJsControllerRef = useRef(null); const syncableTokenCache = useSyncableTokenCache({ suppressTokenCacheNotificationsRef, diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index e5e86d8cd63..cb7c9af64fa 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => { configure: vi.fn(), getClientToken: vi.fn(), nativeClientEvent: null as unknown, - syncFromJsClientToken: vi.fn(), + syncClientStateFromJs: vi.fn(), tokenCache: { clearToken: vi.fn(), getToken: vi.fn(), @@ -90,7 +90,7 @@ vi.mock('../../specs/NativeClerkModule', () => { configure: mocks.configure, getClientToken: mocks.getClientToken, removeListeners: vi.fn(), - syncFromJsClientToken: mocks.syncFromJsClientToken, + syncClientStateFromJs: mocks.syncClientStateFromJs, }, }; }); @@ -111,13 +111,21 @@ vi.mock('../singleton', () => { }; }); +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise(innerResolve => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + describe('ClerkProvider native client sync', () => { beforeEach(() => { vi.clearAllMocks(); mocks.nativeClientEvent = null; mocks.configure.mockResolvedValue(undefined); mocks.getClientToken.mockResolvedValue('native-client-token'); - mocks.syncFromJsClientToken.mockResolvedValue(undefined); + mocks.syncClientStateFromJs.mockResolvedValue(undefined); mocks.tokenCache.getToken.mockResolvedValue('client-token'); mocks.tokenCache.saveToken.mockResolvedValue(undefined); mocks.tokenCache.clearToken.mockResolvedValue(undefined); @@ -142,7 +150,7 @@ describe('ClerkProvider native client sync', () => { }); }); - test('configures native with the cached JS client token during bootstrap', async () => { + test('configures native with the cached device token during bootstrap', async () => { render( { expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); }); - mocks.syncFromJsClientToken.mockClear(); + mocks.syncClientStateFromJs.mockClear(); await act(async () => { await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String), false); + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); }); }); - test('reloads JS resources after native emits a client change with a token', async () => { + test('reloads JS resources after native emits a device token change', async () => { mocks.tokenCache.getToken.mockResolvedValue(null); mocks.getClientToken.mockResolvedValue(null); @@ -195,7 +203,11 @@ describe('ClerkProvider native client sync', () => { mocks.nativeClientEvent = { issuedAt: 1, - clientToken: 'native-client-token', + changed: { + client: false, + deviceToken: true, + }, + deviceToken: 'native-client-token', }; rerender( { expect(mocks.getClientToken).not.toHaveBeenCalled(); }); - test('reloads JS resources after native emits a client change without a token', async () => { + test('reloads JS resources after native clears the device token', async () => { const { rerender } = render( { mocks.nativeClientEvent = { issuedAt: 1, - clientToken: null, + changed: { + client: false, + deviceToken: true, + }, + deviceToken: null, }; rerender( { 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 () => { + mocks.getClientToken.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: 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( { expect(mocks.configure).toHaveBeenCalled(); }); - mocks.syncFromJsClientToken.mockClear(); + mocks.syncClientStateFromJs.mockClear(); mocks.clerkInstance.__internal_reloadInitialResources.mockImplementation(() => { mocks.clerkListener?.(); }); mocks.nativeClientEvent = { issuedAt: 1, - clientToken: 'native-client-token', + changed: { + client: true, + deviceToken: true, + }, + deviceToken: 'native-client-token', }; rerender( { await waitFor(() => { expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); }); - expect(mocks.syncFromJsClientToken).not.toHaveBeenCalled(); + expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); + }); + + test('keeps token cache notifications suppressed across overlapping native token writes', async () => { + mocks.tokenCache.getToken.mockResolvedValue(null); + mocks.getClientToken.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( + , + ); + + await waitFor(() => { + expect(mocks.tokenCache.saveToken).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 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; + mocks.getClientToken.mockResolvedValue(null); + + 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 () => { @@ -322,7 +525,11 @@ describe('ClerkProvider native client sync', () => { mocks.nativeClientEvent = { issuedAt: 1, - clientToken: 'native-client-token', + changed: { + client: true, + deviceToken: true, + }, + deviceToken: 'native-client-token', }; rerender( { mocks.nativeClientEvent = { issuedAt: 1, - clientToken: null, + changed: { + client: true, + deviceToken: true, + }, + deviceToken: null, }; rerender( { ); await waitFor(() => { - expect(originalUpdateClient).toHaveBeenCalledWith( - { - signedInSessions: [], - lastActiveSessionId: null, - }, - { __internal_dangerouslySkipEmit: false }, - ); + 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(); }); @@ -607,7 +822,7 @@ describe('ClerkProvider native client sync', () => { expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: remainingSession }); }); - test('does not fall back to JS sign-out when stale unauthenticated recovery still has a native client token', async () => { + 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', @@ -708,21 +923,21 @@ describe('ClerkProvider native client sync', () => { expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); }); - mocks.syncFromJsClientToken.mockClear(); + mocks.syncClientStateFromJs.mockClear(); mocks.tokenCache.getToken.mockResolvedValue('client-token'); act(() => { mocks.clerkListener?.(); }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith(null, expect.any(String), true); + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false); }); }); test('continues processing queued native sync after a native sync failure', async () => { mocks.tokenCache.getToken.mockResolvedValue(null); let rejectFirstSync: ((error: Error) => void) | undefined; - mocks.syncFromJsClientToken.mockImplementationOnce(() => { + mocks.syncClientStateFromJs.mockImplementationOnce(() => { return new Promise((_resolve, reject) => { rejectFirstSync = reject; }); @@ -744,7 +959,7 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith(null, expect.any(String), true); + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false); }); await act(async () => { @@ -753,14 +968,14 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String), false); + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, 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.syncFromJsClientToken.mockImplementationOnce(() => { + mocks.syncClientStateFromJs.mockImplementationOnce(() => { return new Promise(resolve => { resolveFirstSync = resolve; }); @@ -782,7 +997,7 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String), false); + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); }); act(() => { @@ -794,7 +1009,7 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith(null, expect.any(String), true); + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false); }); }); @@ -812,14 +1027,14 @@ describe('ClerkProvider native client sync', () => { expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); }); - mocks.syncFromJsClientToken.mockClear(); + mocks.syncClientStateFromJs.mockClear(); await act(async () => { await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String), false); + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); }); }); @@ -837,22 +1052,26 @@ describe('ClerkProvider native client sync', () => { expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); }); - mocks.syncFromJsClientToken.mockClear(); + mocks.syncClientStateFromJs.mockClear(); await act(async () => { await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String), false); + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); }); - const sourceId = mocks.syncFromJsClientToken.mock.calls[0]?.[1]; + const sourceId = mocks.syncClientStateFromJs.mock.calls[0]?.[1]; mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); mocks.nativeClientEvent = { issuedAt: 1, - clientToken: 'client-token', + changed: { + client: false, + deviceToken: true, + }, + deviceToken: 'client-token', sourceId, }; rerender( @@ -879,14 +1098,14 @@ describe('ClerkProvider native client sync', () => { expect(mocks.configure).toHaveBeenCalled(); }); - mocks.syncFromJsClientToken.mockClear(); + mocks.syncClientStateFromJs.mockClear(); await act(async () => { await mocks.clerkOptions?.tokenCache?.clearToken?.(CLERK_CLIENT_JWT_KEY); }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith(null, expect.any(String), true); + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), false, true); }); }); }); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 3deffb9281e..6ea6eee4d77 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -5,16 +5,12 @@ 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, - type NativeClientSnapshot, - useNativeClientEvents, -} from '../hooks/useNativeClientEvents'; +import { type NativeClientEvent, useNativeClientEvents } from '../hooks/useNativeClientEvents'; import { ClerkExpoModule as NativeClerkModule } from '../utils/native-module'; const tokenCacheReadTimeoutMs = 1_000; -const clientTokenPollIntervalMs = 100; -const clientTokenAvailabilityTimeoutMs = 3_000; +const nativeDeviceTokenPollIntervalMs = 100; +const nativeDeviceTokenAvailabilityTimeoutMs = 3_000; const nativeClientSyncSourceIdPrefix = 'clerk-expo-js-sync'; export type SyncableClerkInstance = { @@ -34,15 +30,16 @@ type RefreshableClientResource = ClientResource & { }; type NativeRefreshFromJsOptions = { - clientToken?: string | null; - shouldRefreshClient: boolean; + deviceToken?: string | null; + didChangeClient: boolean; + didChangeDeviceToken: boolean; }; export type NativeRefreshFromJsController = { cancel: () => void; }; -export type ClientTokenCacheListener = (clientToken: string | null) => void; +export type DeviceTokenCacheListener = (deviceToken: string | null) => void; function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); @@ -53,9 +50,9 @@ export function useSyncableTokenCache({ tokenCache, tokenCacheListenersRef, }: { - suppressTokenCacheNotificationsRef: MutableRefObject; + suppressTokenCacheNotificationsRef: MutableRefObject; tokenCache: TokenCache | undefined; - tokenCacheListenersRef: MutableRefObject>; + tokenCacheListenersRef: MutableRefObject>; }): TokenCache | undefined { return useMemo(() => { const effectiveTokenCache = @@ -64,13 +61,13 @@ export function useSyncableTokenCache({ return undefined; } - const notifyClientTokenListeners = (clientToken: string | null) => { - if (suppressTokenCacheNotificationsRef.current) { + const notifyDeviceTokenListeners = (deviceToken: string | null) => { + if (suppressTokenCacheNotificationsRef.current > 0) { return; } for (const listener of tokenCacheListenersRef.current) { - listener(clientToken); + listener(deviceToken); } }; @@ -79,104 +76,94 @@ export function useSyncableTokenCache({ saveToken: async (key, token) => { await effectiveTokenCache.saveToken(key, token); if (key === CLERK_CLIENT_JWT_KEY) { - notifyClientTokenListeners(token); + notifyDeviceTokenListeners(token); } }, clearToken: async key => { await effectiveTokenCache.clearToken?.(key); if (key === CLERK_CLIENT_JWT_KEY) { - notifyClientTokenListeners(null); + notifyDeviceTokenListeners(null); } }, }; }, [suppressTokenCacheNotificationsRef, tokenCache, tokenCacheListenersRef]); } -function getNativeClientTokenFromSnapshot( - snapshot: NativeClientSnapshot | null | undefined, -): string | null | undefined { - if (!snapshot || !('clientToken' in snapshot)) { - return undefined; - } - - return snapshot.clientToken ?? null; -} - -async function readNativeClientToken({ waitForToken }: { waitForToken: boolean }): Promise { +async function readNativeDeviceToken({ waitForToken }: { waitForToken: boolean }): Promise { const ClerkExpo = NativeClerkModule; if (!ClerkExpo?.getClientToken) { return null; } const startedAt = Date.now(); - let remainingMs = clientTokenAvailabilityTimeoutMs; + let remainingMs = nativeDeviceTokenAvailabilityTimeoutMs; do { - const nativeClientToken = await ClerkExpo.getClientToken(); - if (nativeClientToken) { - return nativeClientToken; + const nativeDeviceToken = await ClerkExpo.getClientToken(); + if (nativeDeviceToken) { + return nativeDeviceToken; } if (!waitForToken) { return null; } - remainingMs = clientTokenAvailabilityTimeoutMs - (Date.now() - startedAt); + remainingMs = nativeDeviceTokenAvailabilityTimeoutMs - (Date.now() - startedAt); if (remainingMs <= 0) { return null; } - await delay(Math.min(clientTokenPollIntervalMs, remainingMs)); + await delay(Math.min(nativeDeviceTokenPollIntervalMs, remainingMs)); } while (remainingMs > 0); return null; } -async function syncClientTokenToCache(tokenCache: TokenCache | undefined, clientToken: string | null): Promise { - if (clientToken) { - await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, clientToken); +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 syncClientTokenToCacheWithoutNotifying({ - clientToken, +async function syncDeviceTokenToCacheWithoutNotifying({ + deviceToken, suppressTokenCacheNotificationsRef, tokenCache, }: { - clientToken: string | null; - suppressTokenCacheNotificationsRef: MutableRefObject; + deviceToken: string | null; + suppressTokenCacheNotificationsRef: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { - suppressTokenCacheNotificationsRef.current = true; + suppressTokenCacheNotificationsRef.current += 1; try { - await syncClientTokenToCache(tokenCache, clientToken); + await syncDeviceTokenToCache(tokenCache, deviceToken); } finally { - suppressTokenCacheNotificationsRef.current = false; + suppressTokenCacheNotificationsRef.current = Math.max(0, suppressTokenCacheNotificationsRef.current - 1); } } -async function syncNativeTokenToCache({ - clientToken, +async function syncNativeDeviceTokenToCache({ + deviceToken, suppressTokenCacheNotificationsRef, tokenCache, }: { - clientToken: string | null; - suppressTokenCacheNotificationsRef?: MutableRefObject; + deviceToken: string | null; + suppressTokenCacheNotificationsRef?: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { if (suppressTokenCacheNotificationsRef) { - await syncClientTokenToCacheWithoutNotifying({ - clientToken, + await syncDeviceTokenToCacheWithoutNotifying({ + deviceToken, suppressTokenCacheNotificationsRef, tokenCache, }); return; } - await syncClientTokenToCache(tokenCache, clientToken); + await syncDeviceTokenToCache(tokenCache, deviceToken); } function getDefaultSignedInSession(client: ClientResource | null | undefined): SignedInSessionResource | null { @@ -194,7 +181,7 @@ function getDefaultSignedInSession(client: ClientResource | null | undefined): S return client.signedInSessions[0] ?? null; } -async function refreshJsClientWithoutEmitting(clerkInstance: SyncableClerkInstance): Promise { +async function refreshJsClientFromServer(clerkInstance: SyncableClerkInstance): Promise { const client = clerkInstance.client as RefreshableClientResource | undefined; if (typeof client?.fetch !== 'function' || typeof clerkInstance.updateClient !== 'function') { @@ -202,35 +189,35 @@ async function refreshJsClientWithoutEmitting(clerkInstance: SyncableClerkInstan } const refreshedClient = await client.fetch({ fetchMaxTries: 1 }); - const nextSession = getDefaultSignedInSession(refreshedClient); - - clerkInstance.updateClient(refreshedClient, { - __internal_dangerouslySkipEmit: Boolean(nextSession), - }); + clerkInstance.updateClient(refreshedClient); return refreshedClient; } async function refreshJsClientFromNativeState({ clerkInstance, - nativeClientToken, + nativeDeviceToken, reloadInitialResources, + shouldSyncDeviceToken = true, suppressTokenCacheNotificationsRef, tokenCache, }: { clerkInstance: SyncableClerkInstance; - nativeClientToken: string | null; + nativeDeviceToken: string | null; reloadInitialResources: boolean; - suppressTokenCacheNotificationsRef?: MutableRefObject; + shouldSyncDeviceToken?: boolean; + suppressTokenCacheNotificationsRef?: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { - await syncNativeTokenToCache({ - clientToken: nativeClientToken, - suppressTokenCacheNotificationsRef, - tokenCache, - }); + if (shouldSyncDeviceToken) { + await syncNativeDeviceTokenToCache({ + deviceToken: nativeDeviceToken, + suppressTokenCacheNotificationsRef, + tokenCache, + }); + } - const refreshedClient = await refreshJsClientWithoutEmitting(clerkInstance); + const refreshedClient = await refreshJsClientFromServer(clerkInstance); if (refreshedClient) { await reconcileJsActiveSessionFromClient({ clerkInstance, @@ -251,17 +238,17 @@ async function refreshJsClientFromNativeState({ async function reloadJsClientFromNativeState({ clerkInstance, - nativeClientToken, + nativeDeviceToken, suppressTokenCacheNotificationsRef, tokenCache, }: { clerkInstance: SyncableClerkInstance; - nativeClientToken: string; - suppressTokenCacheNotificationsRef?: MutableRefObject; + nativeDeviceToken: string; + suppressTokenCacheNotificationsRef?: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { - await syncNativeTokenToCache({ - clientToken: nativeClientToken, + await syncNativeDeviceTokenToCache({ + deviceToken: nativeDeviceToken, suppressTokenCacheNotificationsRef, tokenCache, }); @@ -273,7 +260,7 @@ async function reloadJsClientFromNativeState({ return Boolean(getDefaultSignedInSession(clerkInstance.client)); } -async function recoverJsClientFromNativeToken({ +async function recoverJsClientFromNativeDeviceToken({ clerkInstance, error, suppressTokenCacheNotificationsRef, @@ -281,22 +268,22 @@ async function recoverJsClientFromNativeToken({ }: { clerkInstance: SyncableClerkInstance; error: unknown; - suppressTokenCacheNotificationsRef: MutableRefObject; + suppressTokenCacheNotificationsRef: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { - const nativeClientToken = await readNativeClientToken({ waitForToken: false }); - if (!nativeClientToken) { + const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); + if (!nativeDeviceToken) { return false; } if (__DEV__) { - console.warn('[NativeClientSync] Failed to refresh JS client with native client token:', error); + console.warn('[NativeClientSync] Failed to refresh JS client with native device token:', error); } try { return await reloadJsClientFromNativeState({ clerkInstance, - nativeClientToken, + nativeDeviceToken, suppressTokenCacheNotificationsRef, tokenCache, }); @@ -355,21 +342,22 @@ function mergePendingNativeRefreshOptions( } const merged: NativeRefreshFromJsOptions = { - shouldRefreshClient: current.shouldRefreshClient || next.shouldRefreshClient, + didChangeClient: current.didChangeClient || next.didChangeClient, + didChangeDeviceToken: current.didChangeDeviceToken || next.didChangeDeviceToken, }; - if ('clientToken' in current) { - merged.clientToken = current.clientToken ?? null; + if ('deviceToken' in current) { + merged.deviceToken = current.deviceToken ?? null; } - if ('clientToken' in next) { - merged.clientToken = next.clientToken ?? null; + if ('deviceToken' in next) { + merged.deviceToken = next.deviceToken ?? null; } return merged; } -async function getCachedClientToken(tokenCache: TokenCache | undefined): Promise { +async function getCachedDeviceToken(tokenCache: TokenCache | undefined): Promise { if (!tokenCache) { return null; } @@ -403,18 +391,23 @@ async function syncNativeClientToJs({ nativeRefreshFromJsControllerRef?: MutableRefObject; nativeClientEvent?: NativeClientEvent | null; suppressJsClientChangedRef?: MutableRefObject; - suppressTokenCacheNotificationsRef?: MutableRefObject; + suppressTokenCacheNotificationsRef?: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { - const nativeClientTokenFromEvent = getNativeClientTokenFromSnapshot(nativeClientEvent); - const nativeClientToken = - nativeClientTokenFromEvent !== undefined - ? nativeClientTokenFromEvent - : await readNativeClientToken({ - waitForToken: !nativeClientEvent, - }); + const didChangeClient = nativeClientEvent?.changed.client ?? true; + const didChangeDeviceToken = nativeClientEvent?.changed.deviceToken ?? true; - if (!nativeClientToken && !nativeClientEvent) { + if (!didChangeClient && !didChangeDeviceToken) { + return; + } + + const nativeDeviceToken = nativeClientEvent + ? nativeClientEvent.deviceToken + : await readNativeDeviceToken({ + waitForToken: true, + }); + + if (!nativeDeviceToken && !nativeClientEvent) { return; } @@ -423,8 +416,9 @@ async function syncNativeClientToJs({ await refreshJsClientFromNativeState({ clerkInstance, - nativeClientToken, + nativeDeviceToken, reloadInitialResources: true, + shouldSyncDeviceToken: didChangeDeviceToken, suppressTokenCacheNotificationsRef, tokenCache, }); @@ -449,9 +443,9 @@ export function NativeClientSync({ clerkInstance: SyncableClerkInstance | null | undefined; nativeRefreshFromJsControllerRef: MutableRefObject; suppressJsClientChangedRef: MutableRefObject; - suppressTokenCacheNotificationsRef: MutableRefObject; + suppressTokenCacheNotificationsRef: MutableRefObject; tokenCache: TokenCache | undefined; - tokenCacheListenersRef: MutableRefObject>; + tokenCacheListenersRef: MutableRefObject>; }): null { const isRefreshingNativeFromJsRef = useRef(false); const pendingNativeRefreshRef = useRef(null); @@ -522,7 +516,12 @@ export function NativeClientSync({ return; } - originalUpdateClient(newClient, options); + if (options) { + originalUpdateClient(newClient, options); + return; + } + + originalUpdateClient(newClient); }; clerkInstance.updateClient = updateClient; @@ -554,13 +553,18 @@ export function NativeClientSync({ return; } - const bearerToken = 'clientToken' in options ? (options.clientToken ?? null) : null; + const deviceToken = options.didChangeDeviceToken ? (options.deviceToken ?? null) : null; if (generation !== nativeRefreshGenerationRef.current) { return; } const sourceId = `${nativeClientSyncSourceIdPrefix}-${generation}`; - await ClerkExpo.syncFromJsClientToken(bearerToken, sourceId, options.shouldRefreshClient); + await ClerkExpo.syncClientStateFromJs( + deviceToken, + sourceId, + options.didChangeClient, + options.didChangeDeviceToken, + ); }; let latestRunGeneration = initialGeneration; @@ -578,7 +582,10 @@ export function NativeClientSync({ console.warn('[NativeClientSync] Failed to refresh native client from JS client change:', error); } } - pendingOptions = pendingNativeRefreshRef.current ?? { shouldRefreshClient: false }; + pendingOptions = pendingNativeRefreshRef.current ?? { + didChangeClient: false, + didChangeDeviceToken: false, + }; if (pendingNativeRefreshRef.current !== null) { generation = nativeRefreshGenerationRef.current + 1; nativeRefreshGenerationRef.current = generation; @@ -592,8 +599,12 @@ export function NativeClientSync({ }, []); useEffect(() => { - const listener: ClientTokenCacheListener = clientToken => { - queueNativeRefreshFromJs({ clientToken, shouldRefreshClient: clientToken === null }); + const listener: DeviceTokenCacheListener = deviceToken => { + queueNativeRefreshFromJs({ + deviceToken, + didChangeClient: false, + didChangeDeviceToken: true, + }); }; const tokenCacheListeners = tokenCacheListenersRef.current; @@ -620,13 +631,13 @@ export function NativeClientSync({ try { return await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { try { - const nativeClientToken = await readNativeClientToken({ waitForToken: false }); + const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); // Native may have already moved the server-side client to a new // active session. Refresh JS before allowing Clerk JS' stale-session // 401 path to collapse the whole client to signed out. const didRecover = await refreshJsClientFromNativeState({ clerkInstance, - nativeClientToken, + nativeDeviceToken, reloadInitialResources: false, suppressTokenCacheNotificationsRef, tokenCache, @@ -635,7 +646,7 @@ export function NativeClientSync({ return; } } catch (error) { - const didRecover = await recoverJsClientFromNativeToken({ + const didRecover = await recoverJsClientFromNativeDeviceToken({ clerkInstance, error, suppressTokenCacheNotificationsRef, @@ -673,7 +684,10 @@ export function NativeClientSync({ return; } - queueNativeRefreshFromJs({ shouldRefreshClient: true }); + queueNativeRefreshFromJs({ + didChangeClient: true, + didChangeDeviceToken: false, + }); }, { skipInitialEmit: true }, ); @@ -693,7 +707,7 @@ export function useNativeClientBootstrap({ clerkInstance, }: { publishableKey: string; - suppressTokenCacheNotificationsRef: MutableRefObject; + suppressTokenCacheNotificationsRef: MutableRefObject; tokenCache: TokenCache | undefined; clerkInstance: SyncableClerkInstance | null | undefined; }) { @@ -723,17 +737,17 @@ export function useNativeClientBootstrap({ return; } - let bearerToken: string | null = null; + let cachedDeviceToken: string | null = null; try { - bearerToken = await getCachedClientToken(tokenCache); + cachedDeviceToken = await getCachedDeviceToken(tokenCache); } catch (e) { if (__DEV__) { console.warn('[ClerkProvider] Token cache read failed:', e); } } - if (bearerToken) { - await ClerkExpo.configure(publishableKey, bearerToken); + if (cachedDeviceToken) { + await ClerkExpo.configure(publishableKey, cachedDeviceToken); if (!isMountedRef.current) { return; @@ -812,7 +826,7 @@ export function useNativeClientEventSync({ isMountedRef: MutableRefObject; nativeRefreshFromJsControllerRef: MutableRefObject; suppressJsClientChangedRef: MutableRefObject; - suppressTokenCacheNotificationsRef: MutableRefObject; + suppressTokenCacheNotificationsRef: MutableRefObject; tokenCache: TokenCache | undefined; }) { const { nativeClientEvent } = useNativeClientEvents(); diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index e14d55f8191..39f8a744922 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -6,10 +6,11 @@ interface Spec { addListener?(eventName: string): void; configure(publishableKey: string, bearerToken: string | null): Promise; getClientToken(): Promise; - syncFromJsClientToken( - clientToken: string | null, + syncClientStateFromJs( + deviceToken: string | null, sourceId: string | null, - shouldRefreshClient?: boolean, + didChangeClient: boolean, + didChangeDeviceToken: boolean, ): Promise; removeListeners?(count: number): void; } diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index 1d8fbab97e0..c77fa90e76f 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -7,10 +7,11 @@ export interface Spec extends TurboModule { addListener(eventName: string): void; configure(publishableKey: string, bearerToken: string | null): Promise; getClientToken(): Promise; - syncFromJsClientToken( - clientToken: string | null, + syncClientStateFromJs( + deviceToken: string | null, sourceId: string | null, - shouldRefreshClient?: boolean, + didChangeClient: boolean, + didChangeDeviceToken: boolean, ): Promise; // Required by NativeEventEmitter for internal native client change events. // This is not part of the public @clerk/expo API. diff --git a/packages/expo/src/utils/__tests__/native-module.test.ts b/packages/expo/src/utils/__tests__/native-module.test.ts index 865b90ac596..ae1161bd80a 100644 --- a/packages/expo/src/utils/__tests__/native-module.test.ts +++ b/packages/expo/src/utils/__tests__/native-module.test.ts @@ -15,7 +15,7 @@ const makeNativeModule = ({ includeEventMethods = true } = {}) => ({ : {}), configure: vi.fn(), getClientToken: vi.fn(), - syncFromJsClientToken: vi.fn(), + syncClientStateFromJs: vi.fn(), }); vi.mock('react-native', () => ({ diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index 0ba2036e8b0..df1bd0c3795 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -9,10 +9,11 @@ type ClerkExpoNativeModule = { configure(publishableKey: string, bearerToken: string | null): Promise; getClientToken(): Promise; removeListeners?(count: number): void; - syncFromJsClientToken( - clientToken: string | null, + syncClientStateFromJs( + deviceToken: string | null, sourceId: string | null, - shouldRefreshClient?: boolean, + didChangeClient: boolean, + didChangeDeviceToken: boolean, ): Promise; }; @@ -25,7 +26,7 @@ function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule { return ( typeof maybeModule.configure === 'function' && typeof maybeModule.getClientToken === 'function' && - typeof maybeModule.syncFromJsClientToken === 'function' + typeof maybeModule.syncClientStateFromJs === 'function' ); }