diff --git a/.changeset/android-native-client-sync.md b/.changeset/android-native-client-sync.md new file mode 100644 index 00000000000..39ad20e88f4 --- /dev/null +++ b/.changeset/android-native-client-sync.md @@ -0,0 +1,5 @@ +--- +"@clerk/expo": patch +--- + +Fix native component auth state when syncing JS and native client changes. 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/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index eeb219a35fd..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 @@ -73,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 + ) } } @@ -169,7 +174,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 +235,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 +256,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") } } @@ -285,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 @@ -295,43 +305,65 @@ class ClerkExpoModule : Module() { try { jsOriginatedClientSyncDepth += 1 if (!clientToken.isNullOrBlank()) { - when (val result = Clerk.updateDeviceToken(clientToken)) { + 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)) { + 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 + } + } + } + } + } + + 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.sessionFlow.first { it != null } - } - } catch (_: TimeoutCancellationException) { - debugLog(TAG, "syncFromJsClientToken - session 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 69a902f28d7..2a638d64ab2 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -21,8 +21,8 @@ private struct ClerkExpoHeaderMiddleware: ClerkRequestMiddleware { // MARK: - Native Bridge Implementation -public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { - public static let shared = ClerkNativeBridge() +final class ClerkNativeBridge: ClerkNativeBridgeProtocol { + static let shared = ClerkNativeBridge() private static let clerkLoadMaxAttempts = 30 private static let clerkLoadIntervalNs: UInt64 = 100_000_000 @@ -53,21 +53,21 @@ public 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 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 @@ public 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 @@ public 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 @@ public 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.. 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 +219,7 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { ) } - public func makeUserProfileViewController( + func makeUserProfileViewController( dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { @@ -245,7 +235,7 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { ) } - public func makeUserButtonViewController() -> UIViewController? { + func makeUserButtonViewController() -> UIViewController? { guard Self.clerkConfigured else { return nil } return makeHostingController( @@ -257,13 +247,17 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } @MainActor - public 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.waitForLoadedSession() - } 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/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 }); 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/__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..0ba2036e8b0 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -5,11 +5,15 @@ 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; - syncFromJsClientToken(clientToken: string | null, sourceId: string | null): Promise; + removeListeners?(count: number): void; + syncFromJsClientToken( + clientToken: string | null, + sourceId: string | null, + shouldRefreshClient?: boolean, + ): Promise; }; function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule { @@ -19,10 +23,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' ); }