From cc8c9c7d703afd78b9ba6d8cf144f50c1fcaeda5 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:45:38 -0400 Subject: [PATCH 1/4] fix(expo): initialize Android native module without event methods --- .changeset/great-rivers-pull.md | 5 +++++ packages/expo/ios/ClerkNativeBridge.swift | 4 ++-- .../src/utils/__tests__/native-module.test.ts | 22 ++++++++++++++----- packages/expo/src/utils/native-module.ts | 6 ++--- 4 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 .changeset/great-rivers-pull.md diff --git a/.changeset/great-rivers-pull.md b/.changeset/great-rivers-pull.md new file mode 100644 index 00000000000..fcf7f11bd70 --- /dev/null +++ b/.changeset/great-rivers-pull.md @@ -0,0 +1,5 @@ +--- +"@clerk/expo": patch +--- + +Fix Android native component initialization when the Expo native module does not expose React Native event listener bookkeeping methods, and make the generated iOS bridge compatible with Swift's explicit import visibility checks. diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 69a902f28d7..bd00b977c2c 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -7,7 +7,7 @@ import SwiftUI import Observation @_spi(FrameworkIntegration) import ClerkKit import ClerkKitUI -import ClerkExpo // Import the pod to access ClerkNativeBridgeProtocol +internal import ClerkExpo // Import the pod to access ClerkNativeBridgeProtocol private struct ClerkExpoHeaderMiddleware: ClerkRequestMiddleware { // Replaced by the config plugin when this bridge is copied into the app target. @@ -21,7 +21,7 @@ private struct ClerkExpoHeaderMiddleware: ClerkRequestMiddleware { // MARK: - Native Bridge Implementation -public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { +final class ClerkNativeBridge: ClerkNativeBridgeProtocol { public static let shared = ClerkNativeBridge() private static let clerkLoadMaxAttempts = 30 diff --git a/packages/expo/src/utils/__tests__/native-module.test.ts b/packages/expo/src/utils/__tests__/native-module.test.ts index 60830f82ab9..865b90ac596 100644 --- a/packages/expo/src/utils/__tests__/native-module.test.ts +++ b/packages/expo/src/utils/__tests__/native-module.test.ts @@ -6,11 +6,15 @@ const mocks = vi.hoisted(() => ({ requireNativeModule: vi.fn(() => undefined as unknown), })); -const makeNativeModule = () => ({ - addListener: vi.fn(), +const makeNativeModule = ({ includeEventMethods = true } = {}) => ({ + ...(includeEventMethods + ? { + addListener: vi.fn(), + removeListeners: vi.fn(), + } + : {}), configure: vi.fn(), getClientToken: vi.fn(), - removeListeners: vi.fn(), syncFromJsClientToken: vi.fn(), }); @@ -41,7 +45,7 @@ describe('native module loader', () => { mocks.requireNativeModule.mockImplementation(() => mocks.expoModule); }); - test('returns the generated native module when it satisfies the sync contract', async () => { + test('returns the generated native module when it satisfies the bootstrap contract', async () => { mocks.nativeModule = makeNativeModule(); const { ClerkExpoModule } = await importNativeModule(); @@ -49,7 +53,15 @@ describe('native module loader', () => { expect(ClerkExpoModule).toBe(mocks.nativeModule); }); - test('returns null when no native module satisfies the sync contract', async () => { + test('returns the generated Android module when it satisfies the bootstrap contract without event methods', async () => { + mocks.nativeModule = makeNativeModule({ includeEventMethods: false }); + + const { ClerkExpoModule } = await importNativeModule(); + + expect(ClerkExpoModule).toBe(mocks.nativeModule); + }); + + test('returns null when no native module satisfies the bootstrap contract', async () => { mocks.nativeModule = { configure: vi.fn(), }; diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index 94e88e70b6f..59adf0b51c1 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -5,10 +5,10 @@ import NativeClerkModule from '../specs/NativeClerkModule'; export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; type ClerkExpoNativeModule = { - addListener(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; + addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; configure(publishableKey: string, bearerToken: string | null): Promise; getClientToken(): Promise; - removeListeners(count: number): void; + removeListeners?(count: number): void; syncFromJsClientToken(clientToken: string | null, sourceId: string | null): Promise; }; @@ -19,10 +19,8 @@ function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule { const maybeModule = module as Record; return ( - typeof maybeModule.addListener === 'function' && typeof maybeModule.configure === 'function' && typeof maybeModule.getClientToken === 'function' && - typeof maybeModule.removeListeners === 'function' && typeof maybeModule.syncFromJsClientToken === 'function' ); } From 56964cf9c7cfb7f70016bb75de4249432241e7d2 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:37:40 -0400 Subject: [PATCH 2/4] fix(expo): guard native client event subscription --- packages/expo/ios/ClerkNativeBridge.swift | 18 +++++----- .../__tests__/useNativeClientEvents.test.ts | 33 ++++++++++++++++--- .../expo/src/hooks/useNativeClientEvents.ts | 19 +++++++++-- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index bd00b977c2c..72d5f003735 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -7,7 +7,7 @@ import SwiftUI import Observation @_spi(FrameworkIntegration) import ClerkKit import ClerkKitUI -internal import ClerkExpo // Import the pod to access ClerkNativeBridgeProtocol +import ClerkExpo // Import the pod to access ClerkNativeBridgeProtocol private struct ClerkExpoHeaderMiddleware: ClerkRequestMiddleware { // Replaced by the config plugin when this bridge is copied into the app target. @@ -22,7 +22,7 @@ private struct ClerkExpoHeaderMiddleware: ClerkRequestMiddleware { // MARK: - Native Bridge Implementation final class ClerkNativeBridge: ClerkNativeBridgeProtocol { - public static let shared = ClerkNativeBridge() + static let shared = ClerkNativeBridge() private static let clerkLoadMaxAttempts = 30 private static let clerkLoadIntervalNs: UInt64 = 100_000_000 @@ -53,13 +53,13 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } // Register this app-target bridge with the ClerkExpo module. - @MainActor public static func register() { + @MainActor static func register() { shared.loadThemes() clerkNativeBridge = shared } @MainActor - public func configure(publishableKey: String, bearerToken: String? = nil) async throws { + func configure(publishableKey: String, bearerToken: String? = nil) async throws { if Self.shouldReconfigure(for: publishableKey) { try await Clerk.reconfigure(publishableKey: publishableKey, options: Self.makeClerkOptions()) Self.clerkConfigured = true @@ -204,14 +204,14 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } @MainActor - public func getClientToken() async -> String? { + func getClientToken() async -> String? { guard Self.clerkConfigured else { return nil } return Clerk.shared.deviceToken } // MARK: - Inline View Creation - public func makeAuthViewController( + func makeAuthViewController( mode: String, dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void @@ -229,7 +229,7 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol { ) } - public func makeUserProfileViewController( + func makeUserProfileViewController( dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { @@ -245,7 +245,7 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol { ) } - public func makeUserButtonViewController() -> UIViewController? { + func makeUserButtonViewController() -> UIViewController? { guard Self.clerkConfigured else { return nil } return makeHostingController( @@ -257,7 +257,7 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } @MainActor - public func syncFromJsClientToken(_ clientToken: String?, sourceId: String?) async throws { + func syncFromJsClientToken(_ clientToken: String?, sourceId: String?) async throws { guard Self.clerkConfigured else { return } if let token = clientToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { diff --git a/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts b/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts index cd5fec6dc8d..e633d735153 100644 --- a/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts +++ b/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts @@ -6,7 +6,11 @@ import { type NativeClientSnapshot, useNativeClientEvents } from '../useNativeCl const mocks = vi.hoisted(() => { return { addListener: vi.fn(), + nativeModule: {} as unknown, nativeListener: undefined as ((snapshot?: NativeClientSnapshot) => void) | undefined, + platform: { + OS: 'ios', + }, remove: vi.fn(), }; }); @@ -16,22 +20,24 @@ vi.mock('react-native', () => { DeviceEventEmitter: { addListener: mocks.addListener, }, - Platform: { - OS: 'ios', - }, + Platform: mocks.platform, }; }); vi.mock('../../utils/native-module', () => { return { - ClerkExpoModule: {}, + get ClerkExpoModule() { + return mocks.nativeModule; + }, isNativeSupported: true, }; }); describe('useNativeClientEvents', () => { beforeEach(() => { + mocks.nativeModule = {}; mocks.nativeListener = undefined; + mocks.platform.OS = 'ios'; mocks.remove.mockReset(); mocks.addListener.mockReset(); mocks.addListener.mockImplementation((_eventName, listener) => { @@ -63,4 +69,23 @@ describe('useNativeClientEvents', () => { unmount(); }); + + test('does not subscribe Android modules without React Native addListener', () => { + mocks.platform.OS = 'android'; + mocks.nativeModule = { + configure: vi.fn(), + getClientToken: vi.fn(), + syncFromJsClientToken: vi.fn(), + }; + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const { unmount } = renderHook(() => useNativeClientEvents()); + + expect(mocks.addListener).not.toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); + + consoleError.mockRestore(); + unmount(); + }); }); diff --git a/packages/expo/src/hooks/useNativeClientEvents.ts b/packages/expo/src/hooks/useNativeClientEvents.ts index be1309bae60..e440a20f188 100644 --- a/packages/expo/src/hooks/useNativeClientEvents.ts +++ b/packages/expo/src/hooks/useNativeClientEvents.ts @@ -32,6 +32,18 @@ type RefreshClientEventEmitter = { ) => RefreshClientEventSubscription; }; +function getNativeClientEventEmitter(): RefreshClientEventEmitter | null { + if (Platform.OS === 'ios') { + return DeviceEventEmitter; + } + + if (ClerkExpo && typeof ClerkExpo.addListener === 'function') { + return ClerkExpo as RefreshClientEventEmitter; + } + + return null; +} + /** * Listens for native client events that should sync JS client state. */ @@ -46,8 +58,11 @@ export function useNativeClientEvents(): UseNativeClientEventsReturn { let subscription: { remove: () => void } | null = null; try { - const eventEmitter: RefreshClientEventEmitter = - Platform.OS === 'ios' ? DeviceEventEmitter : (ClerkExpo as RefreshClientEventEmitter); + const eventEmitter = getNativeClientEventEmitter(); + + if (!eventEmitter) { + return; + } subscription = eventEmitter.addListener(nativeClientChangedEvent, snapshot => { setNativeClientEvent({ issuedAt: Date.now(), ...snapshot }); From b0351cbce759e6b24afecc229e8539b22beac7e7 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:08:06 -0400 Subject: [PATCH 3/4] fix(expo): stabilize native client sync --- .changeset/android-native-client-sync.md | 5 +++ .../expo/modules/clerk/ClerkExpoModule.kt | 29 +++++++++++------ packages/expo/ios/ClerkNativeBridge.swift | 32 +++++++------------ 3 files changed, 36 insertions(+), 30 deletions(-) create mode 100644 .changeset/android-native-client-sync.md diff --git a/.changeset/android-native-client-sync.md b/.changeset/android-native-client-sync.md new file mode 100644 index 00000000000..d644e59f652 --- /dev/null +++ b/.changeset/android-native-client-sync.md @@ -0,0 +1,5 @@ +--- +"@clerk/expo": patch +--- + +Fix Android native component auth state when JS syncs the current client token back to the native SDK. 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 eeb219a35fd..e20f78dc626 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 @@ -5,7 +5,6 @@ import android.util.Log import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.clerk.api.Clerk -import com.clerk.api.network.model.client.Client import com.clerk.api.network.model.error.firstMessage import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.ui.ClerkColors @@ -169,7 +168,7 @@ class ClerkExpoModule : Module() { // before resolving the configure call. if (!bearerToken.isNullOrEmpty()) { withTimeout(5_000L) { - Clerk.sessionFlow.first { it != null } + Clerk.clientFlow.first { it != null } } } } catch (e: TimeoutCancellationException) { @@ -230,10 +229,10 @@ class ClerkExpoModule : Module() { try { withTimeout(5_000L) { - Clerk.sessionFlow.first { it != null } + Clerk.clientFlow.first { it != null } } } catch (_: TimeoutCancellationException) { - debugLog(TAG, "configure - session did not appear after reconfigure token update") + debugLog(TAG, "configure - client did not appear after reconfigure token update") } } @@ -251,13 +250,13 @@ class ClerkExpoModule : Module() { debugLog(TAG, "configure - updateDeviceToken failed: ${result.error}") } - // Wait for session to appear with the new token (up to 5s) + // Wait for client state to hydrate with the new token (up to 5s). try { withTimeout(5_000L) { - Clerk.sessionFlow.first { it != null } + Clerk.clientFlow.first { it != null } } } catch (_: TimeoutCancellationException) { - debugLog(TAG, "configure - session did not appear after token update") + debugLog(TAG, "configure - client did not appear after token update") } } @@ -295,6 +294,18 @@ class ClerkExpoModule : Module() { try { jsOriginatedClientSyncDepth += 1 if (!clientToken.isNullOrBlank()) { + val currentDeviceToken = try { + Clerk.getDeviceToken() + } catch (_: Exception) { + null + } + + if (currentDeviceToken == clientToken) { + emitSyncedClientChanged(sourceId) + promise.resolve(null) + return@launch + } + when (val result = Clerk.updateDeviceToken(clientToken)) { is ClerkResult.Failure -> { promise.reject( @@ -307,10 +318,10 @@ class ClerkExpoModule : Module() { is ClerkResult.Success -> { try { withTimeout(5_000L) { - Clerk.sessionFlow.first { it != null } + Clerk.clientFlow.first { it != null } } } catch (_: TimeoutCancellationException) { - debugLog(TAG, "syncFromJsClientToken - session did not appear after token update") + debugLog(TAG, "syncFromJsClientToken - client did not appear after token update") } emitSyncedClientChanged(sourceId) promise.resolve(null) diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 72d5f003735..e3a6adfdea8 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -66,8 +66,8 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol { Self.configuredPublishableKey = publishableKey startClientObserver(reset: true) - let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) - await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) + let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) + await Self.waitForLoadedClientIfNeeded(shouldWaitForClient) Self.emitClientChangedIfReceivedToken(bearerToken) emitClerkNativeBridgeReady() return @@ -75,8 +75,8 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol { if Self.clerkConfigured { startClientObserver() - let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) - await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) + let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) + await Self.waitForLoadedClientIfNeeded(shouldWaitForClient) Self.emitClientChangedIfReceivedToken(bearerToken) return } @@ -86,8 +86,8 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol { Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions()) startClientObserver() - let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) - await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) + let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) + await Self.waitForLoadedClientIfNeeded(shouldWaitForClient) Self.emitClientChangedIfReceivedToken(bearerToken) emitClerkNativeBridgeReady() } @@ -175,20 +175,10 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol { return .init(keychainConfig: .init(service: service), middleware: middleware) } - @MainActor - private static func waitForLoadedSession() async { - // Wait for Clerk to finish loading (cached data + API refresh). - // The static configure() fires off async refreshes; poll until loaded. - for _ in 0.. Date: Thu, 18 Jun 2026 18:31:56 -0400 Subject: [PATCH 4/4] fix(expo): clarify native client sync intent --- .changeset/android-native-client-sync.md | 2 +- .../expo/modules/clerk/ClerkExpoModule.kt | 81 ++++++++++++------- packages/expo/ios/ClerkExpoModule.m | 1 + packages/expo/ios/ClerkExpoModule.swift | 11 ++- packages/expo/ios/ClerkNativeBridge.swift | 12 ++- .../ClerkProvider.nativeClientSync.test.tsx | 55 +++++++++++-- .../expo/src/provider/nativeClientSync.tsx | 27 ++++--- .../src/specs/NativeClerkModule.android.ts | 6 +- packages/expo/src/specs/NativeClerkModule.ts | 6 +- packages/expo/src/utils/native-module.ts | 6 +- 10 files changed, 148 insertions(+), 59 deletions(-) diff --git a/.changeset/android-native-client-sync.md b/.changeset/android-native-client-sync.md index d644e59f652..39ad20e88f4 100644 --- a/.changeset/android-native-client-sync.md +++ b/.changeset/android-native-client-sync.md @@ -2,4 +2,4 @@ "@clerk/expo": patch --- -Fix Android native component auth state when JS syncs the current client token back to the native SDK. +Fix native component auth state when syncing JS and native client changes. 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 e20f78dc626..175631e9fd5 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 @@ -5,6 +5,7 @@ import android.util.Log import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.clerk.api.Clerk +import com.clerk.api.network.model.client.Client import com.clerk.api.network.model.error.firstMessage import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.ui.ClerkColors @@ -72,8 +73,13 @@ class ClerkExpoModule : Module() { getClientToken(promise) } - AsyncFunction("syncFromJsClientToken") { clientToken: String?, sourceId: String?, promise: Promise -> - syncFromJsClientToken(clientToken, sourceId, promise) + AsyncFunction("syncFromJsClientToken") { clientToken: String?, sourceId: String?, shouldRefreshClient: Boolean?, promise: Promise -> + syncFromJsClientToken( + clientToken, + sourceId, + shouldRefreshClient ?: clientToken.isNullOrBlank(), + promise + ) } } @@ -284,7 +290,12 @@ class ClerkExpoModule : Module() { // MARK: - syncFromJsClientToken - private fun syncFromJsClientToken(clientToken: String?, sourceId: String?, promise: Promise) { + private fun syncFromJsClientToken( + clientToken: String?, + sourceId: String?, + shouldRefreshClient: Boolean, + promise: Promise + ) { if (!Clerk.isInitialized.value) { promise.resolve(null) return @@ -301,48 +312,58 @@ class ClerkExpoModule : Module() { } if (currentDeviceToken == clientToken) { - emitSyncedClientChanged(sourceId) - promise.resolve(null) - return@launch + if (!shouldRefreshClient) { + emitSyncedClientChanged(sourceId) + promise.resolve(null) + return@launch + } + } else { + when (val result = Clerk.updateDeviceToken(clientToken)) { + is ClerkResult.Failure -> { + promise.reject( + "E_SYNC_FROM_JS_FAILED", + result.error?.firstMessage() ?: result.throwable?.message ?: "Client token sync failed", + null + ) + return@launch + } + is ClerkResult.Success -> { + try { + withTimeout(5_000L) { + 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 + } + } + } } + } - when (val result = Clerk.updateDeviceToken(clientToken)) { + if (shouldRefreshClient) { + when (val result = Clerk.refreshClient()) { 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 ?: "Client refresh failed", null ) - return@launch } is ClerkResult.Success -> { - try { - withTimeout(5_000L) { - Clerk.clientFlow.first { it != null } - } - } catch (_: TimeoutCancellationException) { - debugLog(TAG, "syncFromJsClientToken - client did not appear after token update") - } emitSyncedClientChanged(sourceId) promise.resolve(null) - return@launch } } + return@launch } - when (val result = Clerk.refreshClient()) { - is ClerkResult.Failure -> { - promise.reject( - "E_SYNC_FROM_JS_FAILED", - result.error?.firstMessage() ?: result.throwable?.message ?: "Client refresh failed", - null - ) - } - is ClerkResult.Success -> { - emitSyncedClientChanged(sourceId) - promise.resolve(null) - } - } + emitSyncedClientChanged(sourceId) + promise.resolve(null) } catch (e: Exception) { promise.reject("E_SYNC_FROM_JS_FAILED", e.message ?: "Client token sync failed", e) } finally { diff --git a/packages/expo/ios/ClerkExpoModule.m b/packages/expo/ios/ClerkExpoModule.m index 1e1d05875b7..6d74be037cf 100644 --- a/packages/expo/ios/ClerkExpoModule.m +++ b/packages/expo/ios/ClerkExpoModule.m @@ -13,6 +13,7 @@ @interface RCT_EXTERN_MODULE(ClerkExpo, RCTEventEmitter) RCT_EXTERN_METHOD(syncFromJsClientToken:(id)clientToken sourceId:(id)sourceId + shouldRefreshClient:(id)shouldRefreshClient resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index 3880215b7a3..dc54aacbbe5 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -31,7 +31,7 @@ public protocol ClerkNativeBridgeProtocol { // SDK operations func configure(publishableKey: String, bearerToken: String?) async throws func getClientToken() async -> String? - func syncFromJsClientToken(_ clientToken: String?, sourceId: String?) async throws + func syncFromJsClientToken(_ clientToken: String?, sourceId: String?, shouldRefreshClient: Bool) async throws } public protocol ClerkNativeBridgeReadyObserver: AnyObject { @@ -154,6 +154,7 @@ class ClerkExpoModule: RCTEventEmitter { @objc func syncFromJsClientToken(_ clientToken: Any?, sourceId: Any?, + shouldRefreshClient: Any?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { guard let bridge = clerkNativeBridge else { @@ -163,9 +164,15 @@ class ClerkExpoModule: RCTEventEmitter { let normalizedClientToken = clientToken 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 bridge.syncFromJsClientToken(normalizedClientToken, sourceId: normalizedSourceId) + try await bridge.syncFromJsClientToken( + normalizedClientToken, + sourceId: normalizedSourceId, + shouldRefreshClient: normalizedShouldRefreshClient + ) resolve(nil) } catch { reject("E_SYNC_FROM_JS_FAILED", error.localizedDescription, error) diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index e3a6adfdea8..2a638d64ab2 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -247,13 +247,17 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } @MainActor - func syncFromJsClientToken(_ clientToken: String?, sourceId: String?) async throws { + func syncFromJsClientToken(_ clientToken: String?, sourceId: String?, shouldRefreshClient: Bool) async throws { guard Self.clerkConfigured else { return } if let token = clientToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { - _ = try await Clerk.shared.updateDeviceToken(token) - await Self.waitForLoadedClient() - } else { + if Clerk.shared.deviceToken != token { + _ = try await Clerk.shared.updateDeviceToken(token) + await Self.waitForLoadedClient() + } + } + + if shouldRefreshClient { _ = try await Clerk.shared.refreshClient() await Self.waitForLoadedClient() } diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index c26f1bf6e6a..e5e86d8cd63 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -171,7 +171,7 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String)); + expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String), false); }); }); @@ -715,7 +715,7 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith(null, expect.any(String)); + expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith(null, expect.any(String), true); }); }); @@ -744,7 +744,7 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith(null, expect.any(String)); + expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith(null, expect.any(String), true); }); await act(async () => { @@ -753,7 +753,48 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String)); + expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String), false); + }); + }); + + 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(() => { + 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.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String), false); + }); + + act(() => { + mocks.clerkListener?.(); + }); + + await act(async () => { + resolveFirstSync?.(); + }); + + await waitFor(() => { + expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith(null, expect.any(String), true); }); }); @@ -778,7 +819,7 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String)); + expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String), false); }); }); @@ -803,7 +844,7 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String)); + expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith('client-token', expect.any(String), false); }); const sourceId = mocks.syncFromJsClientToken.mock.calls[0]?.[1]; @@ -845,7 +886,7 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith(null, expect.any(String)); + expect(mocks.syncFromJsClientToken).toHaveBeenCalledWith(null, expect.any(String), true); }); }); }); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 059d8dfd350..3deffb9281e 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -35,6 +35,7 @@ type RefreshableClientResource = ClientResource & { type NativeRefreshFromJsOptions = { clientToken?: string | null; + shouldRefreshClient: boolean; }; export type NativeRefreshFromJsController = { @@ -353,13 +354,19 @@ function mergePendingNativeRefreshOptions( return next; } + const merged: NativeRefreshFromJsOptions = { + shouldRefreshClient: current.shouldRefreshClient || next.shouldRefreshClient, + }; + + if ('clientToken' in current) { + merged.clientToken = current.clientToken ?? null; + } + if ('clientToken' in next) { - return { - clientToken: next.clientToken ?? null, - }; + merged.clientToken = next.clientToken ?? null; } - return current; + return merged; } async function getCachedClientToken(tokenCache: TokenCache | undefined): Promise { @@ -529,10 +536,6 @@ export function NativeClientSync({ const queueNativeRefreshFromJs = useCallback((options: NativeRefreshFromJsOptions): void => { if (isRefreshingNativeFromJsRef.current) { - if (!('clientToken' in options)) { - return; - } - pendingNativeRefreshRef.current = mergePendingNativeRefreshOptions(pendingNativeRefreshRef.current, options); nativeRefreshGenerationRef.current += 1; return; @@ -557,7 +560,7 @@ export function NativeClientSync({ } const sourceId = `${nativeClientSyncSourceIdPrefix}-${generation}`; - await ClerkExpo.syncFromJsClientToken(bearerToken, sourceId); + await ClerkExpo.syncFromJsClientToken(bearerToken, sourceId, options.shouldRefreshClient); }; let latestRunGeneration = initialGeneration; @@ -575,7 +578,7 @@ export function NativeClientSync({ console.warn('[NativeClientSync] Failed to refresh native client from JS client change:', error); } } - pendingOptions = pendingNativeRefreshRef.current ?? {}; + pendingOptions = pendingNativeRefreshRef.current ?? { shouldRefreshClient: false }; if (pendingNativeRefreshRef.current !== null) { generation = nativeRefreshGenerationRef.current + 1; nativeRefreshGenerationRef.current = generation; @@ -590,7 +593,7 @@ export function NativeClientSync({ useEffect(() => { const listener: ClientTokenCacheListener = clientToken => { - queueNativeRefreshFromJs({ clientToken }); + queueNativeRefreshFromJs({ clientToken, shouldRefreshClient: clientToken === null }); }; const tokenCacheListeners = tokenCacheListenersRef.current; @@ -670,7 +673,7 @@ export function NativeClientSync({ return; } - queueNativeRefreshFromJs({}); + queueNativeRefreshFromJs({ shouldRefreshClient: true }); }, { skipInitialEmit: true }, ); diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index ae77f91da86..e14d55f8191 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -6,7 +6,11 @@ interface Spec { addListener?(eventName: string): void; configure(publishableKey: string, bearerToken: string | null): Promise; getClientToken(): Promise; - syncFromJsClientToken(clientToken: string | null, sourceId: string | null): Promise; + syncFromJsClientToken( + clientToken: string | null, + sourceId: string | null, + shouldRefreshClient?: boolean, + ): Promise; removeListeners?(count: number): void; } diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index 4527defafcf..1d8fbab97e0 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -7,7 +7,11 @@ export interface Spec extends TurboModule { addListener(eventName: string): void; configure(publishableKey: string, bearerToken: string | null): Promise; getClientToken(): Promise; - syncFromJsClientToken(clientToken: string | null, sourceId: string | null): Promise; + syncFromJsClientToken( + clientToken: string | null, + sourceId: string | null, + shouldRefreshClient?: boolean, + ): Promise; // Required by NativeEventEmitter for internal native client change events. // This is not part of the public @clerk/expo API. removeListeners(count: number): void; diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index 59adf0b51c1..0ba2036e8b0 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -9,7 +9,11 @@ type ClerkExpoNativeModule = { configure(publishableKey: string, bearerToken: string | null): Promise; getClientToken(): Promise; removeListeners?(count: number): void; - syncFromJsClientToken(clientToken: string | null, sourceId: string | null): Promise; + syncFromJsClientToken( + clientToken: string | null, + sourceId: string | null, + shouldRefreshClient?: boolean, + ): Promise; }; function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule {