From bccb5bdec58c2dc1e141793a33aaee9663ffe993 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:16:44 -0400 Subject: [PATCH 1/5] fix(expo): sync iOS native client via ClerkKit --- .changeset/quiet-ravens-remember.md | 5 + packages/expo/ios/ClerkNativeBridge.swift | 154 ++-------------- .../hooks/__tests__/useNativeSession.test.ts | 103 +++++++++++ .../expo/src/hooks/nativeSessionEvents.ts | 15 ++ packages/expo/src/hooks/useNativeSession.ts | 9 +- packages/expo/src/provider/ClerkProvider.tsx | 4 + .../ClerkProvider.nativeSession.test.tsx | 167 ++++++++++++++++++ 7 files changed, 313 insertions(+), 144 deletions(-) create mode 100644 .changeset/quiet-ravens-remember.md create mode 100644 packages/expo/src/hooks/__tests__/useNativeSession.test.ts create mode 100644 packages/expo/src/hooks/nativeSessionEvents.ts create mode 100644 packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx diff --git a/.changeset/quiet-ravens-remember.md b/.changeset/quiet-ravens-remember.md new file mode 100644 index 00000000000..800960df0c9 --- /dev/null +++ b/.changeset/quiet-ravens-remember.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Fix iOS standalone session persistence after JS-owned sign-in by syncing the JS client token through ClerkKit's native device-token integration API, and keep `useNativeSession()` in sync after native client refreshes. diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 41171f4a760..e02e4c2b1a5 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -1,12 +1,11 @@ // ClerkNativeBridge - Provides app-target Clerk SDK operations and SwiftUI view controllers to ClerkExpo. // This file is injected into the app target by the config plugin. -// It uses `import ClerkKit` (SPM) which is only accessible from the app target. +// It uses the ClerkKit Swift package, which is only accessible from the app target. import UIKit import SwiftUI import Observation -import Security -import ClerkKit +@_spi(FrameworkIntegration) import ClerkKit import ClerkKitUI import ClerkExpo // Import the pod to access ClerkNativeBridgeProtocol @@ -27,13 +26,6 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { private var clientObservationGeneration = 0 private var lastObservedClient: Client? - private enum KeychainKey { - static let jsClientJWT = "__clerk_client_jwt" - static let nativeDeviceToken = "clerkDeviceToken" - static let cachedClient = "cachedClient" - static let cachedEnvironment = "cachedEnvironment" - } - private init() {} /// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first @@ -45,11 +37,6 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { return Bundle.main.bundleIdentifier } - private static var keychain: ExpoKeychain? { - guard let service = keychainService, !service.isEmpty else { return nil } - return ExpoKeychain(service: service) - } - // Register this app-target bridge with the ClerkExpo module. @MainActor public static func register() { shared.loadThemes() @@ -64,29 +51,15 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { Self.configuredPublishableKey = publishableKey startClientObserver(reset: true) - Self.syncTokenState(bearerToken: bearerToken) - if !(bearerToken?.isEmpty ?? true) { - _ = try? await Clerk.shared.refreshClient() - } - - await Self.waitForLoadedSession() - return - } - - Self.syncTokenState(bearerToken: bearerToken) - - // If already configured with a new bearer token, refresh the client - // to pick up the session associated with the device token we just wrote. - // Clerk.configure() is idempotent for the same publishable key, so use refreshClient(). - if Self.shouldRefreshConfiguredClient(for: bearerToken) { - startClientObserver() - _ = try? await Clerk.shared.refreshClient() + try await Self.syncTokenState(bearerToken: bearerToken) await Self.waitForLoadedSession() return } if Self.clerkConfigured { startClientObserver() + try await Self.syncTokenState(bearerToken: bearerToken) + await Self.waitForLoadedSession() return } @@ -95,6 +68,7 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions()) startClientObserver() + try await Self.syncTokenState(bearerToken: bearerToken) await Self.waitForLoadedSession() } @@ -129,30 +103,9 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } } - private static func syncTokenState(bearerToken: String?) { - // Sync JS SDK's client token to native keychain so both SDKs share the same client. - // This handles the case where the user signed in via JS SDK but the native SDK - // has no device token (e.g., after app reinstall or first launch). - if let token = bearerToken, !token.isEmpty { - let existingToken = readNativeDeviceToken() - writeNativeDeviceToken(token) - - // If the device token changed (or didn't exist), clear stale cached client/environment. - // A previous launch may have cached an anonymous client (no device token), and the - // SDK would send both the new device token AND the stale client ID in API requests, - // causing a 400 error. Clearing the cache forces a fresh client fetch using only - // the device token. - if existingToken != token { - clearCachedClerkData() - } - return - } - - syncJSTokenToNativeKeychainIfNeeded() - } - - private static func shouldRefreshConfiguredClient(for bearerToken: String?) -> Bool { - clerkConfigured && !(bearerToken?.isEmpty ?? true) + private static func syncTokenState(bearerToken: String?) async throws { + guard let token = bearerToken, !token.isEmpty else { return } + _ = try await Clerk.shared.updateDeviceToken(token) } private static func shouldReconfigure(for publishableKey: String) -> Bool { @@ -179,39 +132,9 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } } - /// Copies the JS SDK's client JWT from expo-secure-store to the native SDK's - /// keychain entry, but only if the native SDK doesn't already have a device token. - /// Both expo-secure-store and the native Clerk SDK use the iOS Keychain with the - /// bundle identifier as the service name, making cross-SDK token sharing possible. - private static func syncJSTokenToNativeKeychainIfNeeded() { - guard let keychain else { return } - guard keychain.string(forKey: KeychainKey.nativeDeviceToken) == nil else { return } - guard let jsToken = keychain.string(forKey: KeychainKey.jsClientJWT), !jsToken.isEmpty else { return } - - keychain.set(jsToken, forKey: KeychainKey.nativeDeviceToken) - } - - /// Reads the native device token from keychain, if present. - private static func readNativeDeviceToken() -> String? { - keychain?.string(forKey: KeychainKey.nativeDeviceToken) - } - - /// Clears stale cached client and environment data from keychain. - /// This prevents the native SDK from loading a stale anonymous client - /// during initialization, which would conflict with a newly-synced device token. - private static func clearCachedClerkData() { - keychain?.delete(KeychainKey.cachedClient) - keychain?.delete(KeychainKey.cachedEnvironment) - } - - /// Writes the provided bearer token as the native SDK's device token. - /// If the native SDK already has a device token, it is updated with the new value. - private static func writeNativeDeviceToken(_ token: String) { - keychain?.set(token, forKey: KeychainKey.nativeDeviceToken) - } - public func getClientToken() -> String? { - Self.readNativeDeviceToken() + guard Self.clerkConfigured else { return nil } + return Clerk.shared.deviceToken } // MARK: - Inline View Creation @@ -410,61 +333,6 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } } -private struct ExpoKeychain { - private let service: String - - init(service: String) { - self.service = service - } - - func string(forKey key: String) -> String? { - guard let data = data(forKey: key) else { return nil } - return String(data: data, encoding: .utf8) - } - - func set(_ value: String, forKey key: String) { - guard let data = value.data(using: .utf8) else { return } - - var addQuery = baseQuery(for: key) - addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - addQuery[kSecValueData as String] = data - - let status = SecItemAdd(addQuery as CFDictionary, nil) - if status == errSecDuplicateItem { - let attributes: [String: Any] = [ - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - ] - SecItemUpdate(baseQuery(for: key) as CFDictionary, attributes as CFDictionary) - } - } - - func delete(_ key: String) { - SecItemDelete(baseQuery(for: key) as CFDictionary) - } - - private func data(forKey key: String) -> Data? { - var query = baseQuery(for: key) - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var result: CFTypeRef? - guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { - return nil - } - - return result as? Data - } - - private func baseQuery(for key: String) -> [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - ] - } -} - // MARK: - Inline User Button Wrapper (for embedded rendering) struct ClerkInlineUserButtonWrapperView: View { diff --git a/packages/expo/src/hooks/__tests__/useNativeSession.test.ts b/packages/expo/src/hooks/__tests__/useNativeSession.test.ts new file mode 100644 index 00000000000..f53485d6211 --- /dev/null +++ b/packages/expo/src/hooks/__tests__/useNativeSession.test.ts @@ -0,0 +1,103 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { notifyNativeSessionChanged } from '../nativeSessionEvents'; +import { useNativeSession } from '../useNativeSession'; + +const mocks = vi.hoisted(() => { + return { + getSession: vi.fn(), + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { + OS: 'ios', + }, + }; +}); + +vi.mock('../../specs/NativeClerkModule', () => { + return { + default: { + configure: vi.fn(), + getSession: mocks.getSession, + getClientToken: vi.fn(), + refreshClient: vi.fn(), + }, + }; +}); + +describe('useNativeSession', () => { + beforeEach(() => { + mocks.getSession.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + test('reads the native session on mount', async () => { + mocks.getSession.mockResolvedValue({ + sessionId: 'sess_123', + user: { id: 'user_123' }, + }); + + const { result } = renderHook(() => useNativeSession()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isAvailable).toBe(true); + expect(result.current.isSignedIn).toBe(true); + expect(result.current.sessionId).toBe('sess_123'); + expect(result.current.user?.id).toBe('user_123'); + }); + + test('refreshes when the native session changes', async () => { + mocks.getSession.mockResolvedValueOnce(null).mockResolvedValueOnce({ + sessionId: 'sess_456', + user: { id: 'user_456' }, + }); + + const { result } = renderHook(() => useNativeSession()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.isSignedIn).toBe(false); + + act(() => { + notifyNativeSessionChanged(); + }); + + await waitFor(() => { + expect(result.current.sessionId).toBe('sess_456'); + }); + + expect(result.current.isSignedIn).toBe(true); + expect(result.current.user?.id).toBe('user_456'); + }); + + test('removes the native session listener on unmount', async () => { + mocks.getSession.mockResolvedValue(null); + + const { unmount } = renderHook(() => useNativeSession()); + + await waitFor(() => { + expect(mocks.getSession).toHaveBeenCalledTimes(1); + }); + + unmount(); + + act(() => { + notifyNativeSessionChanged(); + }); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mocks.getSession).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/expo/src/hooks/nativeSessionEvents.ts b/packages/expo/src/hooks/nativeSessionEvents.ts new file mode 100644 index 00000000000..67b4f8b59f4 --- /dev/null +++ b/packages/expo/src/hooks/nativeSessionEvents.ts @@ -0,0 +1,15 @@ +type NativeSessionListener = () => void; + +const listeners = new Set(); + +export function addNativeSessionListener(listener: NativeSessionListener): () => void { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +} + +export function notifyNativeSessionChanged(): void { + listeners.forEach(listener => listener()); +} diff --git a/packages/expo/src/hooks/useNativeSession.ts b/packages/expo/src/hooks/useNativeSession.ts index a05a4a8c341..46478f81a19 100644 --- a/packages/expo/src/hooks/useNativeSession.ts +++ b/packages/expo/src/hooks/useNativeSession.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; +import { addNativeSessionListener } from './nativeSessionEvents'; // Native session data structure (normalized) interface NativeSessionData { @@ -114,7 +115,13 @@ export function useNativeSession(): UseNativeSessionReturn { // Check native session on mount useEffect(() => { - refresh(); + void refresh(); + }, [refresh]); + + useEffect(() => { + return addNativeSessionListener(() => { + void refresh(); + }); }, [refresh]); return { diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index f7d0f158614..ff2381689d7 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -7,6 +7,7 @@ import { Platform } from 'react-native'; import type { TokenCache } from '../cache/types'; import { CLERK_CLIENT_JWT_KEY } from '../constants'; +import { notifyNativeSessionChanged } from '../hooks/nativeSessionEvents'; import { useNativeClientEvents } from '../hooks/useNativeClientEvents'; import NativeClerkModule from '../specs/NativeClerkModule'; import { tokenCache as defaultTokenCache } from '../token-cache'; @@ -167,6 +168,7 @@ function NativeClientSync({ // No token to push; ask native to reload its current client. await ClerkExpo.refreshClient(); } + notifyNativeSessionChanged(); }; void refreshNativeFromJsClient() @@ -232,6 +234,7 @@ function useNativeSessionBootstrap({ if (!isMountedRef.current) { return; } + notifyNativeSessionChanged(); if (clerkInstance) { const waitForLoad = (): Promise => { @@ -349,6 +352,7 @@ export function ClerkProvider(props: ClerkProviderProps { + return { + configure: vi.fn(), + getClientToken: vi.fn(), + nativeClientEvent: null as unknown, + notifyNativeSessionChanged: vi.fn(), + refreshClient: vi.fn(), + tokenCache: { + clearToken: vi.fn(), + getToken: vi.fn(), + saveToken: vi.fn(), + }, + clerkInstance: { + __internal_reloadInitialResources: vi.fn(), + addListener: vi.fn(), + client: { + lastActiveSessionId: 'sess_native', + }, + loaded: true, + session: { + id: null, + }, + setActive: vi.fn(), + }, + }; +}); + +vi.mock('../../polyfills', () => ({})); + +vi.mock('@clerk/react/internal', () => { + return { + InternalClerkProvider: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + }; +}); + +vi.mock('react-native', () => { + return { + NativeModules: { + BlobModule: {}, + }, + Platform: { + OS: 'ios', + constants: { + reactNativeVersion: { + major: 0, + minor: 81, + patch: 0, + }, + }, + }, + }; +}); + +vi.mock('expo-secure-store', () => { + return { + AFTER_FIRST_UNLOCK: 0, + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), + }; +}); + +vi.mock('../../hooks/nativeSessionEvents', () => { + return { + notifyNativeSessionChanged: mocks.notifyNativeSessionChanged, + }; +}); + +vi.mock('../../hooks/useNativeClientEvents', () => { + return { + useNativeClientEvents: () => ({ + nativeClientEvent: mocks.nativeClientEvent, + }), + }; +}); + +vi.mock('../../specs/NativeClerkModule', () => { + return { + default: { + configure: mocks.configure, + getClientToken: mocks.getClientToken, + refreshClient: mocks.refreshClient, + }, + }; +}); + +vi.mock('../../utils/runtime', () => { + return { + isNative: () => true, + isWeb: () => false, + }; +}); + +vi.mock('../singleton', () => { + return { + getClerkInstance: () => mocks.clerkInstance, + }; +}); + +describe('ClerkProvider native session notifications', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.nativeClientEvent = null; + mocks.configure.mockResolvedValue(undefined); + mocks.getClientToken.mockResolvedValue('native-client-token'); + mocks.tokenCache.getToken.mockResolvedValue('client-token'); + mocks.tokenCache.saveToken.mockResolvedValue(undefined); + mocks.tokenCache.clearToken.mockResolvedValue(undefined); + mocks.clerkInstance.addListener.mockReturnValue(vi.fn()); + mocks.clerkInstance.client.lastActiveSessionId = 'sess_native'; + mocks.clerkInstance.session.id = null; + }); + + test('refreshes useNativeSession subscribers after initial native configure', async () => { + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); + }); + + await waitFor(() => { + expect(mocks.notifyNativeSessionChanged).toHaveBeenCalled(); + }); + }); + + test('refreshes useNativeSession subscribers after native client events sync back to JS', async () => { + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.notifyNativeSessionChanged).toHaveBeenCalled(); + }); + mocks.notifyNativeSessionChanged.mockClear(); + + mocks.nativeClientEvent = { type: 'refreshClient' }; + rerender( + , + ); + + await waitFor(() => { + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); + }); + expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); + expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: 'sess_native' }); + expect(mocks.notifyNativeSessionChanged).toHaveBeenCalledTimes(1); + }); +}); From 6223106d429667c2293078d6bb3e446129cad00d Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:01:58 -0400 Subject: [PATCH 2/5] Make Expo client token bridge async --- packages/expo/ios/ClerkExpoModule.swift | 7 +++++-- packages/expo/ios/ClerkNativeBridge.swift | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index 462fef7e812..284869980df 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -25,7 +25,7 @@ public protocol ClerkNativeBridgeProtocol { // SDK operations func configure(publishableKey: String, bearerToken: String?) async throws func getSession() async -> [String: Any]? - func getClientToken() -> String? + func getClientToken() async -> String? func refreshClient() async throws } @@ -110,7 +110,10 @@ class ClerkExpoModule: RCTEventEmitter { return } - resolve(bridge.getClientToken()) + Task { + let token = await bridge.getClientToken() + resolve(token) + } } // MARK: - refreshClient diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index e02e4c2b1a5..4420cdb92b9 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -132,7 +132,8 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } } - public func getClientToken() -> String? { + @MainActor + public func getClientToken() async -> String? { guard Self.clerkConfigured else { return nil } return Clerk.shared.deviceToken } From 718cf0c69b9d6a3465626a48ae18204db074a43b Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:58:28 -0400 Subject: [PATCH 3/5] fix(expo): sync JS sessions to native iOS --- .changeset/fuzzy-keys-sync.md | 5 + packages/expo/ios/ClerkNativeBridge.swift | 38 ++-- packages/expo/ios/ClerkNativeViewHost.swift | 30 ++- packages/expo/src/provider/ClerkProvider.tsx | 181 ++++++++++++++---- .../ClerkProvider.nativeSession.test.tsx | 66 +++++++ packages/expo/src/utils/native-module.ts | 23 ++- 6 files changed, 291 insertions(+), 52 deletions(-) create mode 100644 .changeset/fuzzy-keys-sync.md diff --git a/.changeset/fuzzy-keys-sync.md b/.changeset/fuzzy-keys-sync.md new file mode 100644 index 00000000000..5c4adc86c93 --- /dev/null +++ b/.changeset/fuzzy-keys-sync.md @@ -0,0 +1,5 @@ +--- +"@clerk/expo": patch +--- + +Fix iOS native Clerk components and `useNativeSession()` staying signed out after a user signs in through the Expo JavaScript SDK. diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 4420cdb92b9..332cb64eaaa 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -51,15 +51,15 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { Self.configuredPublishableKey = publishableKey startClientObserver(reset: true) - try await Self.syncTokenState(bearerToken: bearerToken) - await Self.waitForLoadedSession() + let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) + await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) return } if Self.clerkConfigured { startClientObserver() - try await Self.syncTokenState(bearerToken: bearerToken) - await Self.waitForLoadedSession() + let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) + await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) return } @@ -68,8 +68,8 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions()) startClientObserver() - try await Self.syncTokenState(bearerToken: bearerToken) - await Self.waitForLoadedSession() + let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) + await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) } @MainActor @@ -103,9 +103,13 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } } - private static func syncTokenState(bearerToken: String?) async throws { - guard let token = bearerToken, !token.isEmpty else { return } + @MainActor + private static func syncTokenState(bearerToken: String?) async throws -> Bool { + guard let token = bearerToken, !token.isEmpty else { + return Clerk.shared.deviceToken != nil + } _ = try await Clerk.shared.updateDeviceToken(token) + return true } private static func shouldReconfigure(for publishableKey: String) -> Bool { @@ -132,6 +136,12 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } } + @MainActor + private static func waitForLoadedSessionIfNeeded(_ shouldWait: Bool) async { + guard shouldWait else { return } + await waitForLoadedSession() + } + @MainActor public func getClientToken() async -> String? { guard Self.clerkConfigured else { return nil } @@ -145,7 +155,9 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { - makeHostingController( + guard Self.clerkConfigured else { return nil } + + return makeHostingController( rootView: ClerkInlineAuthWrapperView( mode: Self.authMode(from: mode), dismissible: dismissible, @@ -160,7 +172,9 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { - makeHostingController( + guard Self.clerkConfigured else { return nil } + + return makeHostingController( rootView: ClerkInlineProfileWrapperView( dismissible: dismissible, lightTheme: lightTheme, @@ -171,7 +185,9 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } public func makeUserButtonViewController() -> UIViewController? { - makeHostingController( + guard Self.clerkConfigured else { return nil } + + return makeHostingController( rootView: ClerkInlineUserButtonWrapperView( lightTheme: lightTheme, darkTheme: darkTheme diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo/ios/ClerkNativeViewHost.swift index 58385486eb3..4f841fa3c5a 100644 --- a/packages/expo/ios/ClerkNativeViewHost.swift +++ b/packages/expo/ios/ClerkNativeViewHost.swift @@ -3,6 +3,11 @@ import UIKit public class ClerkNativeViewHost: UIView { private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) private var hasInitialized: Bool = false + private var pendingHostedViewRetry: DispatchWorkItem? + private var hostedViewRetryCount = 0 + + private static let maxHostedViewRetryCount = 50 + private static let hostedViewRetryDelay: TimeInterval = 0.1 override public init(frame: CGRect) { super.init(frame: frame) @@ -37,6 +42,7 @@ public class ClerkNativeViewHost: UIView { func setNeedsHostedViewUpdate() { guard hasInitialized else { return } + hostedViewRetryCount = 0 updateHostedView() } @@ -50,9 +56,31 @@ public class ClerkNativeViewHost: UIView { func hostedViewDidDetachFromWindow() {} private func updateHostedView() { - guard let controller = makeHostedController() else { return } + guard let controller = makeHostedController() else { + scheduleHostedViewRetry() + return + } + + pendingHostedViewRetry?.cancel() + pendingHostedViewRetry = nil + hostedViewRetryCount = 0 hostingCoordinator.attach(controller) } + + private func scheduleHostedViewRetry() { + guard pendingHostedViewRetry == nil else { return } + guard hostedViewRetryCount < Self.maxHostedViewRetryCount else { return } + + hostedViewRetryCount += 1 + let workItem = DispatchWorkItem { [weak self] in + guard let self, self.hasInitialized else { return } + self.pendingHostedViewRetry = nil + self.updateHostedView() + } + + pendingHostedViewRetry = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + Self.hostedViewRetryDelay, execute: workItem) + } } private final class ClerkNativeHostingCoordinator { diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index ff2381689d7..915877df12a 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -1,16 +1,17 @@ import '../polyfills'; +import { useAuth } from '@clerk/react'; import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react'; import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal'; -import { type MutableRefObject, useEffect, useRef } from 'react'; +import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'; import { Platform } from 'react-native'; import type { TokenCache } from '../cache/types'; import { CLERK_CLIENT_JWT_KEY } from '../constants'; import { notifyNativeSessionChanged } from '../hooks/nativeSessionEvents'; import { useNativeClientEvents } from '../hooks/useNativeClientEvents'; -import NativeClerkModule from '../specs/NativeClerkModule'; import { tokenCache as defaultTokenCache } from '../token-cache'; +import { ClerkExpoModule as NativeClerkModule } from '../utils/native-module'; import { isNative, isWeb } from '../utils/runtime'; import { maybeCompleteAuthSession } from './maybeCompleteAuthSession'; import { getClerkInstance } from './singleton'; @@ -54,6 +55,8 @@ const SDK_METADATA = { version: PACKAGE_VERSION, }; +const tokenCacheReadTimeoutMs = 1_000; + type SyncableClerkInstance = { addListener?: (listener: (payload?: unknown) => void, options?: { skipInitialEmit?: boolean }) => () => void; addOnLoaded?: (listener: () => void) => void; @@ -92,16 +95,69 @@ async function syncClientTokenToCache(tokenCache: TokenCache | undefined, client } } +function hasActiveJsSession(clerkInstance: SyncableClerkInstance): boolean { + return Boolean(clerkInstance.session?.id || clerkInstance.client?.lastActiveSessionId); +} + +async function getCachedClientToken(tokenCache: TokenCache | undefined): Promise { + if (!tokenCache) { + return null; + } + + let timeoutId: ReturnType | undefined; + try { + return ( + (await Promise.race([ + tokenCache.getToken(CLERK_CLIENT_JWT_KEY), + new Promise(resolve => { + timeoutId = setTimeout(() => resolve(null), tokenCacheReadTimeoutMs); + }), + ])) ?? null + ); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} + +async function readCachedClientToken({ + tokenCache, + waitForToken, +}: { + tokenCache: TokenCache | undefined; + waitForToken: boolean; +}): Promise { + const maxAttempts = waitForToken ? 30 : 1; + const intervalMs = 100; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const token = await getCachedClientToken(tokenCache); + if (token || !waitForToken) { + return token; + } + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + + return null; +} + async function syncNativeClientToJs({ clerkInstance, + clearMissingNativeToken, tokenCache, }: { clerkInstance: SyncableClerkInstance; + clearMissingNativeToken: boolean; tokenCache: TokenCache | undefined; }): Promise { const nativeClientToken = await waitForNativeClientToken(); const effectiveTokenCache = tokenCache ?? defaultTokenCache; + if (!nativeClientToken && !clearMissingNativeToken) { + return; + } + await syncClientTokenToCache(effectiveTokenCache, nativeClientToken); if (typeof clerkInstance.__internal_reloadInitialResources === 'function') { await clerkInstance.__internal_reloadInitialResources(); @@ -137,53 +193,89 @@ function NativeClientSync({ publishableKey: string; tokenCache: TokenCache | undefined; }): null { + const { isLoaded, sessionId } = useAuth(); const isRefreshingNativeFromJsRef = useRef(false); + const pendingNativeRefreshWaitForTokenRef = useRef(null); // Use the provided tokenCache, falling back to the default SecureStore cache const effectiveTokenCache = tokenCache ?? defaultTokenCache; - useEffect(() => { - if (!clerkInstance || typeof clerkInstance.addListener !== 'function') { - return; - } + const queueNativeRefreshFromJs = useCallback( + (waitForToken: boolean): void => { + if (isSyncingNativeClientToJsRef.current && !waitForToken) { + return; + } - return clerkInstance.addListener( - () => { - if (isSyncingNativeClientToJsRef.current || isRefreshingNativeFromJsRef.current) { + if (isRefreshingNativeFromJsRef.current) { + pendingNativeRefreshWaitForTokenRef.current = + pendingNativeRefreshWaitForTokenRef.current === true || waitForToken; + return; + } + + isRefreshingNativeFromJsRef.current = true; + + const refreshNativeFromJsClient = async (shouldWaitForToken: boolean): Promise => { + const ClerkExpo = NativeClerkModule; + if (!ClerkExpo) { return; } - isRefreshingNativeFromJsRef.current = true; - - const refreshNativeFromJsClient = async (): Promise => { - const ClerkExpo = NativeClerkModule; - if (!ClerkExpo) { - return; + const bearerToken = await readCachedClientToken({ + tokenCache: effectiveTokenCache, + waitForToken: shouldWaitForToken, + }); + if (bearerToken) { + // configure writes the token and refreshes native client state. + await ClerkExpo.configure(publishableKey, bearerToken); + } else { + const nativeClientToken = (await ClerkExpo.getClientToken?.()) ?? null; + if (nativeClientToken) { + // No JS token to push, but native has a stored client token to reload. + await ClerkExpo.refreshClient(); } + } + notifyNativeSessionChanged(); + }; - const bearerToken = (await effectiveTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; - if (bearerToken) { - // configure writes the token and refreshes native client state. - await ClerkExpo.configure(publishableKey, bearerToken); - } else { - // No token to push; ask native to reload its current client. - await ClerkExpo.refreshClient(); + void (async () => { + let waitForPendingToken = waitForToken; + do { + pendingNativeRefreshWaitForTokenRef.current = null; + await refreshNativeFromJsClient(waitForPendingToken); + waitForPendingToken = pendingNativeRefreshWaitForTokenRef.current ?? false; + } while (pendingNativeRefreshWaitForTokenRef.current !== null); + })() + .catch((error: unknown) => { + if (__DEV__) { + console.warn('[NativeClientSync] Failed to refresh native client from JS client change:', error); } - notifyNativeSessionChanged(); - }; + }) + .finally(() => { + isRefreshingNativeFromJsRef.current = false; + }); + }, + [effectiveTokenCache, isSyncingNativeClientToJsRef, publishableKey], + ); - void refreshNativeFromJsClient() - .catch((error: unknown) => { - if (__DEV__) { - console.warn('[NativeClientSync] Failed to refresh native client from JS client change:', error); - } - }) - .finally(() => { - isRefreshingNativeFromJsRef.current = false; - }); + useEffect(() => { + if (!isLoaded) { + return; + } + + queueNativeRefreshFromJs(Boolean(sessionId)); + }, [isLoaded, queueNativeRefreshFromJs, sessionId]); + + useEffect(() => { + if (!clerkInstance || typeof clerkInstance.addListener !== 'function') { + return; + } + + return clerkInstance.addListener( + () => { + queueNativeRefreshFromJs(hasActiveJsSession(clerkInstance)); }, { skipInitialEmit: true }, ); - }, [clerkInstance, effectiveTokenCache, isSyncingNativeClientToJsRef, publishableKey]); + }, [clerkInstance, queueNativeRefreshFromJs]); return null; } @@ -219,22 +311,31 @@ function useNativeSessionBootstrap({ const ClerkExpo = NativeClerkModule; if (ClerkExpo?.configure) { + await ClerkExpo.configure(publishableKey, null); + + if (!isMountedRef.current) { + return; + } + notifyNativeSessionChanged(); + const effectiveTokenCache = tokenCache ?? defaultTokenCache; let bearerToken: string | null = null; try { - bearerToken = (await effectiveTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + bearerToken = await getCachedClientToken(effectiveTokenCache); } catch (e) { if (__DEV__) { console.warn('[ClerkProvider] Token cache read failed:', e); } } - await ClerkExpo.configure(publishableKey, bearerToken); + if (bearerToken) { + await ClerkExpo.configure(publishableKey, bearerToken); - if (!isMountedRef.current) { - return; + if (!isMountedRef.current) { + return; + } + notifyNativeSessionChanged(); } - notifyNativeSessionChanged(); if (clerkInstance) { const waitForLoad = (): Promise => { @@ -264,6 +365,7 @@ function useNativeSessionBootstrap({ try { await syncNativeClientToJs({ clerkInstance, + clearMissingNativeToken: false, tokenCache, }); } finally { @@ -350,6 +452,7 @@ export function ClerkProvider(props: ClerkProviderProps { }, setActive: vi.fn(), }, + authState: { + isLoaded: true, + sessionId: null as string | null, + }, }; }); vi.mock('../../polyfills', () => ({})); +vi.mock('@clerk/react', () => { + return { + useAuth: () => mocks.authState, + }; +}); + vi.mock('@clerk/react/internal', () => { return { InternalClerkProvider: ({ children }: { children: ReactNode }) => @@ -117,6 +127,8 @@ describe('ClerkProvider native session notifications', () => { mocks.clerkInstance.addListener.mockReturnValue(vi.fn()); mocks.clerkInstance.client.lastActiveSessionId = 'sess_native'; mocks.clerkInstance.session.id = null; + mocks.authState.isLoaded = true; + mocks.authState.sessionId = null; }); test('refreshes useNativeSession subscribers after initial native configure', async () => { @@ -164,4 +176,58 @@ describe('ClerkProvider native session notifications', () => { expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: 'sess_native' }); expect(mocks.notifyNativeSessionChanged).toHaveBeenCalledTimes(1); }); + + test('does not refresh native from JS when neither side has a client token', async () => { + mocks.tokenCache.getToken.mockResolvedValue(null); + mocks.getClientToken.mockResolvedValue(null); + mocks.clerkInstance.client.lastActiveSessionId = null; + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + }); + await waitFor(() => { + expect(mocks.tokenCache.getToken).toHaveBeenCalled(); + }); + + expect(mocks.refreshClient).not.toHaveBeenCalled(); + }); + + test('pushes the cached JS client token to native after JS sign-in', async () => { + mocks.tokenCache.getToken.mockResolvedValue(null); + mocks.getClientToken.mockResolvedValue(null); + mocks.clerkInstance.client.lastActiveSessionId = null; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + }); + + mocks.configure.mockClear(); + mocks.tokenCache.getToken.mockResolvedValue('client-token'); + mocks.authState.sessionId = 'sess_js'; + + rerender( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); + }); + }); }); diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index cdab7fb0e7e..839fa93d02e 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -8,12 +8,33 @@ function loadNativeModule(): typeof NativeClerkModule | null { if (!isNativeSupported) { return null; } + let nativeModule: typeof NativeClerkModule | null = null; + try { - return NativeClerkModule; + nativeModule = NativeClerkModule; } catch (e) { if (__DEV__) { console.warn('[ClerkExpo] Native module not available:', e); } + } + + if (nativeModule?.configure) { + return nativeModule; + } + + try { + // Expo SDK 54 can expose installed modules through Expo's module registry even + // when the generated TurboModule object is incomplete. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { requireNativeModule } = require('expo'); + return requireNativeModule('ClerkExpo') ?? nativeModule; + } catch (e) { + if (__DEV__ && !nativeModule) { + console.warn('[ClerkExpo] Native module not available:', e); + } + if (nativeModule) { + return nativeModule; + } return null; } } From c3bd08c5b7ace9722a776aef2fb2d27884c0834f Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:16:25 -0400 Subject: [PATCH 4/5] fix(expo): notify native views when iOS bridge is ready --- packages/expo/ios/ClerkExpoModule.swift | 36 ++++++++++++++++++- packages/expo/ios/ClerkNativeBridge.swift | 4 +++ packages/expo/ios/ClerkNativeViewHost.swift | 38 +++++---------------- 3 files changed, 47 insertions(+), 31 deletions(-) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index 284869980df..857137a8025 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -13,7 +13,13 @@ public enum ClerkNativeViewEvent: String { } // Global registry for the app-target native bridge (set by the app target at startup) -public var clerkNativeBridge: ClerkNativeBridgeProtocol? +public var clerkNativeBridge: ClerkNativeBridgeProtocol? { + didSet { + if clerkNativeBridge != nil { + emitClerkNativeBridgeReady() + } + } +} // Protocol that the app target implements to provide Clerk SDK operations and SwiftUI views. public protocol ClerkNativeBridgeProtocol { @@ -29,6 +35,34 @@ public protocol ClerkNativeBridgeProtocol { func refreshClient() async throws } +public protocol ClerkNativeBridgeReadyObserver: AnyObject { + func clerkNativeBridgeDidBecomeReady() +} + +private let clerkNativeBridgeReadyObservers = NSHashTable.weakObjects() + +public func addClerkNativeBridgeReadyObserver(_ observer: ClerkNativeBridgeReadyObserver) { + clerkNativeBridgeReadyObservers.add(observer) +} + +public func removeClerkNativeBridgeReadyObserver(_ observer: ClerkNativeBridgeReadyObserver) { + clerkNativeBridgeReadyObservers.remove(observer) +} + +public func emitClerkNativeBridgeReady() { + let notifyObservers = { + for observer in clerkNativeBridgeReadyObservers.allObjects { + (observer as? ClerkNativeBridgeReadyObserver)?.clerkNativeBridgeDidBecomeReady() + } + } + + if Thread.isMainThread { + notifyObservers() + } else { + DispatchQueue.main.async(execute: notifyObservers) + } +} + // MARK: - Module @objc(ClerkExpo) diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 332cb64eaaa..45d4ee93f89 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -53,6 +53,7 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) + emitClerkNativeBridgeReady() return } @@ -60,6 +61,7 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { startClientObserver() let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) + emitClerkNativeBridgeReady() return } @@ -70,6 +72,7 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) + emitClerkNativeBridgeReady() } @MainActor @@ -208,6 +211,7 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { guard Self.clerkConfigured else { return } _ = try await Clerk.shared.refreshClient() await Self.waitForLoadedSession() + emitClerkNativeBridgeReady() } private static func authMode(from mode: String) -> AuthView.Mode { diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo/ios/ClerkNativeViewHost.swift index 4f841fa3c5a..445aa429f78 100644 --- a/packages/expo/ios/ClerkNativeViewHost.swift +++ b/packages/expo/ios/ClerkNativeViewHost.swift @@ -1,13 +1,8 @@ import UIKit -public class ClerkNativeViewHost: UIView { +public class ClerkNativeViewHost: UIView, ClerkNativeBridgeReadyObserver { private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) private var hasInitialized: Bool = false - private var pendingHostedViewRetry: DispatchWorkItem? - private var hostedViewRetryCount = 0 - - private static let maxHostedViewRetryCount = 50 - private static let hostedViewRetryDelay: TimeInterval = 0.1 override public init(frame: CGRect) { super.init(frame: frame) @@ -24,6 +19,7 @@ public class ClerkNativeViewHost: UIView { if hasInitialized { hostedViewDidDetachFromWindow() } + removeClerkNativeBridgeReadyObserver(self) hostingCoordinator.detach() hasInitialized = false return @@ -31,6 +27,7 @@ public class ClerkNativeViewHost: UIView { guard !hasInitialized else { return } hasInitialized = true + addClerkNativeBridgeReadyObserver(self) hostedViewDidAttachToWindow() updateHostedView() } @@ -42,7 +39,6 @@ public class ClerkNativeViewHost: UIView { func setNeedsHostedViewUpdate() { guard hasInitialized else { return } - hostedViewRetryCount = 0 updateHostedView() } @@ -55,31 +51,13 @@ public class ClerkNativeViewHost: UIView { func hostedViewDidDetachFromWindow() {} - private func updateHostedView() { - guard let controller = makeHostedController() else { - scheduleHostedViewRetry() - return - } - - pendingHostedViewRetry?.cancel() - pendingHostedViewRetry = nil - hostedViewRetryCount = 0 - hostingCoordinator.attach(controller) + public func clerkNativeBridgeDidBecomeReady() { + setNeedsHostedViewUpdate() } - private func scheduleHostedViewRetry() { - guard pendingHostedViewRetry == nil else { return } - guard hostedViewRetryCount < Self.maxHostedViewRetryCount else { return } - - hostedViewRetryCount += 1 - let workItem = DispatchWorkItem { [weak self] in - guard let self, self.hasInitialized else { return } - self.pendingHostedViewRetry = nil - self.updateHostedView() - } - - pendingHostedViewRetry = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + Self.hostedViewRetryDelay, execute: workItem) + private func updateHostedView() { + guard let controller = makeHostedController() else { return } + hostingCoordinator.attach(controller) } } From 01f017713a23bf75cbf9782f024ef4eb6d4f9ec0 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:29:56 -0400 Subject: [PATCH 5/5] chore(expo): sort ClerkProvider imports --- packages/expo/src/provider/ClerkProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 915877df12a..7e0a918f536 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -1,7 +1,7 @@ import '../polyfills'; -import { useAuth } from '@clerk/react'; import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react'; +import { useAuth } from '@clerk/react'; import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal'; import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'; import { Platform } from 'react-native';