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/9] 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/9] 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/9] 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/9] 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/9] 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'; From c7b54880adc2d1219103f7bd00888b86764a9cbc Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:48:29 -0400 Subject: [PATCH 6/9] fix(expo): sync native sign-out to JS --- packages/expo/src/provider/ClerkProvider.tsx | 4 +- .../ClerkProvider.nativeSession.test.tsx | 84 ++++++++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 7e0a918f536..69c97c059eb 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -63,7 +63,7 @@ type SyncableClerkInstance = { client?: { lastActiveSessionId?: string | null } | null; loaded?: boolean; session?: { id?: string | null } | null; - setActive?: (params: { session: string }) => void | Promise; + setActive?: (params: { session: string | null }) => void | Promise; __internal_reloadInitialResources?: () => void | Promise; }; @@ -172,6 +172,8 @@ async function syncNativeClientToJs({ typeof clerkInstance.setActive === 'function' ) { await clerkInstance.setActive({ session: nativeActiveSessionId }); + } else if (!nativeActiveSessionId && jsActiveSessionId && typeof clerkInstance.setActive === 'function') { + await clerkInstance.setActive({ session: null }); } } diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx index 528eeaf6518..f87b7a32395 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx @@ -148,7 +148,11 @@ describe('ClerkProvider native session notifications', () => { }); }); - test('refreshes useNativeSession subscribers after native client events sync back to JS', async () => { + test('activates the JS session after native signs in', async () => { + mocks.tokenCache.getToken.mockResolvedValue(null); + mocks.getClientToken.mockResolvedValue(null); + mocks.clerkInstance.client.lastActiveSessionId = null; + const { rerender } = render( { await waitFor(() => { expect(mocks.notifyNativeSessionChanged).toHaveBeenCalled(); }); + expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled(); + + mocks.getClientToken.mockResolvedValue('native-client-token'); + mocks.clerkInstance.client.lastActiveSessionId = 'sess_native'; + mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); + mocks.clerkInstance.setActive.mockClear(); mocks.notifyNativeSessionChanged.mockClear(); mocks.nativeClientEvent = { type: 'refreshClient' }; @@ -177,6 +187,44 @@ describe('ClerkProvider native session notifications', () => { expect(mocks.notifyNativeSessionChanged).toHaveBeenCalledTimes(1); }); + test('clears the JS active session after native signs out', async () => { + mocks.clerkInstance.client.lastActiveSessionId = 'sess_js'; + mocks.clerkInstance.session.id = 'sess_js'; + mocks.authState.sessionId = 'sess_js'; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.notifyNativeSessionChanged).toHaveBeenCalled(); + }); + expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled(); + + mocks.clerkInstance.client.lastActiveSessionId = null; + mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); + mocks.clerkInstance.setActive.mockClear(); + 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: null }); + 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); @@ -230,4 +278,38 @@ describe('ClerkProvider native session notifications', () => { expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); }); }); + + test('refreshes native after JS signs out', async () => { + mocks.authState.sessionId = 'sess_js'; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); + }); + + mocks.configure.mockClear(); + mocks.notifyNativeSessionChanged.mockClear(); + mocks.refreshClient.mockClear(); + mocks.tokenCache.getToken.mockResolvedValue(null); + mocks.authState.sessionId = null; + + rerender( + , + ); + + await waitFor(() => { + expect(mocks.refreshClient).toHaveBeenCalled(); + }); + expect(mocks.configure).not.toHaveBeenCalled(); + expect(mocks.notifyNativeSessionChanged).toHaveBeenCalled(); + }); }); From 7a782a99612e8b1981a0082f5dc7a4af1dfb6038 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:32:09 -0400 Subject: [PATCH 7/9] fix(expo): sync native auth state reactively --- .changeset/fuzzy-keys-sync.md | 5 - .changeset/quiet-ravens-remember.md | 2 +- .../expo/modules/clerk/ClerkExpoModule.kt | 101 +++++--- packages/expo/ios/ClerkExpoModule.m | 4 + packages/expo/ios/ClerkExpoModule.swift | 29 ++- packages/expo/ios/ClerkNativeBridge.swift | 112 ++++++++- .../__tests__/useNativeClientEvents.test.ts | 81 +++++++ .../hooks/__tests__/useNativeSession.test.ts | 51 ++++ .../expo/src/hooks/nativeSessionEvents.ts | 20 +- .../expo/src/hooks/useNativeClientEvents.ts | 13 +- packages/expo/src/hooks/useNativeSession.ts | 35 +-- packages/expo/src/provider/ClerkProvider.tsx | 228 +++++++++++++++--- .../ClerkProvider.nativeSession.test.tsx | 82 ++++++- .../src/specs/NativeClerkModule.android.ts | 1 + packages/expo/src/specs/NativeClerkModule.ts | 1 + 15 files changed, 650 insertions(+), 115 deletions(-) delete mode 100644 .changeset/fuzzy-keys-sync.md create mode 100644 packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts diff --git a/.changeset/fuzzy-keys-sync.md b/.changeset/fuzzy-keys-sync.md deleted file mode 100644 index 5c4adc86c93..00000000000 --- a/.changeset/fuzzy-keys-sync.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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/.changeset/quiet-ravens-remember.md b/.changeset/quiet-ravens-remember.md index 800960df0c9..bffab3586ec 100644 --- a/.changeset/quiet-ravens-remember.md +++ b/.changeset/quiet-ravens-remember.md @@ -2,4 +2,4 @@ '@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. +Fix Expo native Clerk components and `useNativeSession()` staying stale when authentication changes between the JavaScript and native SDKs. JS-owned sign-in now hydrates native components on cold start, and sign-out from either JS or native updates the other side. 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 9ef266fb523..bac89b05081 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 @@ -41,7 +41,8 @@ class ClerkExpoModule : Module() { private var sharedInstance: ClerkExpoModule? = null fun emitRefreshClient() { - sharedInstance?.sendEvent("refreshClient", emptyMap()) + val instance = sharedInstance ?: return + instance.sendEvent("refreshClient", instance.currentAuthStatePayload()) } } @@ -77,6 +78,10 @@ class ClerkExpoModule : Module() { AsyncFunction("refreshClient") { promise: Promise -> refreshClient(promise) } + + AsyncFunction("signOut") { sessionId: String?, promise: Promise -> + signOut(sessionId, promise) + } } private val reactContext: Context? @@ -101,6 +106,44 @@ class ClerkExpoModule : Module() { } } + private fun currentAuthStatePayload(): Map { + val session = Clerk.session + val user = Clerk.user + val result = mutableMapOf( + "sessionId" to session?.id, + "clientToken" to try { + Clerk.getDeviceToken() + } catch (e: Exception) { + debugLog(TAG, "currentAuthStatePayload - getDeviceToken failed: ${e.message}") + null + } + ) + + result["session"] = session?.let { + mapOf( + "id" to it.id, + "status" to it.status.name, + "userId" to it.user?.id + ) + } + + result["user"] = user?.let { + val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId } + val primaryPhone = it.phoneNumbers.find { p -> p.id == it.primaryPhoneNumberId } + + mapOf( + "id" to it.id, + "firstName" to it.firstName, + "lastName" to it.lastName, + "imageUrl" to it.imageUrl, + "primaryEmailAddress" to primaryEmail?.emailAddress, + "primaryPhoneNumber" to primaryPhone?.phoneNumber + ) + } + + return result + } + // MARK: - configure private fun configure(pubKey: String, bearerToken: String?, promise: Promise) { @@ -255,34 +298,7 @@ class ClerkExpoModule : Module() { return } - val session = Clerk.session - val user = Clerk.user - - val result = mutableMapOf() - - session?.let { - result["session"] = mapOf( - "id" to it.id, - "status" to it.status.name, - "userId" to it.user?.id - ) - } - - user?.let { - val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId } - val primaryPhone = it.phoneNumbers.find { p -> p.id == it.primaryPhoneNumberId } - - result["user"] = mapOf( - "id" to it.id, - "firstName" to it.firstName, - "lastName" to it.lastName, - "imageUrl" to it.imageUrl, - "primaryEmailAddress" to primaryEmail?.emailAddress, - "primaryPhoneNumber" to primaryPhone?.phoneNumber - ) - } - - promise.resolve(result) + promise.resolve(currentAuthStatePayload()) } // MARK: - getClientToken @@ -324,6 +340,33 @@ class ClerkExpoModule : Module() { } } + // MARK: - signOut + + private fun signOut(sessionId: String?, promise: Promise) { + if (!Clerk.isInitialized.value) { + promise.resolve(null) + return + } + + coroutineScope.launch { + try { + when (val result = Clerk.auth.signOut(sessionId = sessionId)) { + is ClerkResult.Failure -> promise.reject( + "E_SIGN_OUT_FAILED", + result.error?.firstMessage() ?: result.throwable?.message ?: "Sign-out failed", + null + ) + is ClerkResult.Success -> { + emitRefreshClient() + promise.resolve(null) + } + } + } catch (e: Exception) { + promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign-out failed", e) + } + } + } + // MARK: - Theme Loading private fun loadThemeFromAssets(context: Context) { diff --git a/packages/expo/ios/ClerkExpoModule.m b/packages/expo/ios/ClerkExpoModule.m index a0bb2e88f08..672bc75f51e 100644 --- a/packages/expo/ios/ClerkExpoModule.m +++ b/packages/expo/ios/ClerkExpoModule.m @@ -17,4 +17,8 @@ @interface RCT_EXTERN_MODULE(ClerkExpo, RCTEventEmitter) RCT_EXTERN_METHOD(refreshClient:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(signOut:(NSString *)sessionId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + @end diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index 857137a8025..f53fc4b22d5 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -33,6 +33,7 @@ public protocol ClerkNativeBridgeProtocol { func getSession() async -> [String: Any]? func getClientToken() async -> String? func refreshClient() async throws + func signOut(sessionId: String?) async throws } public protocol ClerkNativeBridgeReadyObserver: AnyObject { @@ -94,9 +95,9 @@ class ClerkExpoModule: RCTEventEmitter { /// Emits a refreshClient event to JS from anywhere in the native layer. /// Used by native views to ask ClerkProvider to reload JS client state. - static func emitRefreshClient() { + static func emitRefreshClient(_ body: [String: Any]? = nil) { guard _hasListeners, let instance = sharedInstance else { return } - instance.sendEvent(withName: "refreshClient", body: nil) + instance.sendEvent(withName: "refreshClient", body: body ?? [:]) } // MARK: - configure @@ -169,9 +170,29 @@ class ClerkExpoModule: RCTEventEmitter { } } + // MARK: - signOut + + @objc func signOut(_ sessionId: String?, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + guard let bridge = clerkNativeBridge else { + resolve(nil) + return + } + + Task { + do { + try await bridge.signOut(sessionId: sessionId) + resolve(nil) + } catch { + reject("E_SIGN_OUT_FAILED", error.localizedDescription, error) + } + } + } + } /// Requests that ClerkProvider reload the JS client from native client state. -public func emitClerkNativeRefreshClient() { - ClerkExpoModule.emitRefreshClient() +public func emitClerkNativeRefreshClient(_ body: [String: Any]? = nil) { + ClerkExpoModule.emitRefreshClient(body) } diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 45d4ee93f89..682ad16304e 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -24,10 +24,19 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { var darkTheme: ClerkTheme? private var clientObservationGeneration = 0 - private var lastObservedClient: Client? + private var lastObservedAuthState: AuthStateSnapshot? + private var authEventObservationTask: Swift.Task? private init() {} + private struct AuthStateSnapshot: Equatable { + let clientId: String? + let sessionId: String? + let userId: String? + let sessionIds: [String] + let deviceToken: String? + } + /// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first /// (for extension apps sharing a keychain group), then falling back to the bundle identifier. private static var keychainService: String? { @@ -50,6 +59,7 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { Self.clerkConfigured = true Self.configuredPublishableKey = publishableKey startClientObserver(reset: true) + startAuthEventObserver(reset: true) let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) @@ -59,6 +69,7 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { if Self.clerkConfigured { startClientObserver() + startAuthEventObserver() let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) emitClerkNativeBridgeReady() @@ -69,6 +80,7 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { Self.configuredPublishableKey = publishableKey Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions()) startClientObserver() + startAuthEventObserver() let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) @@ -81,24 +93,24 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { clientObservationGeneration += 1 let generation = clientObservationGeneration - lastObservedClient = Clerk.shared.client + lastObservedAuthState = Self.authStateSnapshot() observeClient(generation: generation) } @MainActor private func observeClient(generation: Int) { withObservationTracking { - _ = Clerk.shared.client + _ = Self.authStateSnapshot() } onChange: { [weak self] in Task { @MainActor [weak self] in await Task.yield() guard let self, generation == self.clientObservationGeneration else { return } - let newClient = Clerk.shared.client - if newClient != self.lastObservedClient { - self.lastObservedClient = newClient - emitClerkNativeRefreshClient() + let newAuthState = Self.authStateSnapshot() + if newAuthState != self.lastObservedAuthState { + self.lastObservedAuthState = newAuthState + emitClerkNativeRefreshClient(Self.authStatePayload()) } self.observeClient(generation: generation) @@ -106,6 +118,70 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } } + @MainActor + private func startAuthEventObserver(reset: Bool = false) { + if reset { + authEventObservationTask?.cancel() + authEventObservationTask = nil + } + + guard authEventObservationTask == nil else { return } + + authEventObservationTask = Swift.Task { @MainActor [weak self] in + for await event in Clerk.shared.auth.events { + await self?.handleAuthEvent(event) + } + } + } + + @MainActor + private func handleAuthEvent(_ event: AuthEvent) async { + switch event { + case .sessionChanged(_, _), .signInCompleted(_), .signUpCompleted(_): + emitClerkNativeRefreshClient(Self.authStatePayload()) + case .signedOut(_), .accountDeleted: + do { + _ = try await Clerk.shared.refreshClient() + } catch { + // The auth event still represents the latest user action; emit below so + // JS consumers can clear stale state even if the follow-up refresh fails. + } + emitClerkNativeRefreshClient(Self.authStatePayload()) + case .signInNeedsContinuation(_), .signUpNeedsContinuation(_), .tokenRefreshed(_): + break + } + } + + @MainActor + private static func authStateSnapshot() -> AuthStateSnapshot { + let client = Clerk.shared.client + let session = Clerk.shared.session + + return AuthStateSnapshot( + clientId: client?.id, + sessionId: session?.id, + userId: Clerk.shared.user?.id, + sessionIds: client?.sessions.map(\.id) ?? [], + deviceToken: Clerk.shared.deviceToken + ) + } + + @MainActor + private static func authStatePayload() -> [String: Any] { + var payload: [String: Any] = [:] + payload["sessionId"] = Clerk.shared.session?.id ?? NSNull() + payload["clientToken"] = Clerk.shared.deviceToken ?? NSNull() + + if let session = Clerk.shared.session { + payload.merge(sessionPayload(from: session, user: session.user ?? Clerk.shared.user)) { _, new in new } + } else { + payload["session"] = NSNull() + payload["user"] = NSNull() + } + + return payload + } + @MainActor private static func syncTokenState(bearerToken: String?) async throws -> Bool { guard let token = bearerToken, !token.isEmpty else { @@ -139,6 +215,16 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } } + @MainActor + private static func waitForLoadedClient() async { + for _ in 0.. { + return { + addListener: vi.fn(), + nativeListener: undefined as ((snapshot?: NativeSessionSnapshot) => void) | undefined, + remove: vi.fn(), + }; +}); + +vi.mock('react-native', () => { + return { + NativeEventEmitter: vi.fn().mockImplementation(() => ({ + addListener: mocks.addListener, + })), + Platform: { + OS: 'ios', + }, + }; +}); + +vi.mock('../../utils/native-module', () => { + return { + ClerkExpoModule: {}, + isNativeSupported: true, + }; +}); + +describe('useNativeClientEvents', () => { + beforeEach(() => { + mocks.nativeListener = undefined; + mocks.remove.mockReset(); + mocks.addListener.mockReset(); + mocks.addListener.mockImplementation((_eventName, listener) => { + mocks.nativeListener = listener; + return { remove: mocks.remove }; + }); + }); + + afterEach(() => { + cleanup(); + }); + + test('forwards native refresh payloads to native session listeners', async () => { + const sessionSnapshots: Array = []; + const removeNativeSessionListener = addNativeSessionListener(snapshot => { + sessionSnapshots.push(snapshot); + }); + + const { result, unmount } = renderHook(() => useNativeClientEvents()); + + act(() => { + mocks.nativeListener?.({ + sessionId: 'sess_123', + clientToken: 'client-token', + user: { id: 'user_123' }, + }); + }); + + await waitFor(() => { + expect(result.current.nativeClientEvent?.sessionId).toBe('sess_123'); + }); + + expect(result.current.nativeClientEvent?.clientToken).toBe('client-token'); + expect(result.current.nativeClientEvent?.user?.id).toBe('user_123'); + expect(sessionSnapshots).toEqual([ + { + sessionId: 'sess_123', + clientToken: 'client-token', + user: { id: 'user_123' }, + }, + ]); + + removeNativeSessionListener(); + unmount(); + }); +}); diff --git a/packages/expo/src/hooks/__tests__/useNativeSession.test.ts b/packages/expo/src/hooks/__tests__/useNativeSession.test.ts index f53485d6211..ee7e9c4120a 100644 --- a/packages/expo/src/hooks/__tests__/useNativeSession.test.ts +++ b/packages/expo/src/hooks/__tests__/useNativeSession.test.ts @@ -81,6 +81,57 @@ describe('useNativeSession', () => { expect(result.current.user?.id).toBe('user_456'); }); + test('applies native session event payload without fetching the session', async () => { + mocks.getSession.mockResolvedValue(null); + + const { result } = renderHook(() => useNativeSession()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + mocks.getSession.mockClear(); + + act(() => { + notifyNativeSessionChanged({ + sessionId: 'sess_789', + user: { id: 'user_789' }, + }); + }); + + expect(result.current.isSignedIn).toBe(true); + expect(result.current.sessionId).toBe('sess_789'); + expect(result.current.user?.id).toBe('user_789'); + expect(mocks.getSession).not.toHaveBeenCalled(); + }); + + test('applies native signed-out event payload without fetching the session', async () => { + mocks.getSession.mockResolvedValue({ + sessionId: 'sess_123', + user: { id: 'user_123' }, + }); + + const { result } = renderHook(() => useNativeSession()); + + await waitFor(() => { + expect(result.current.sessionId).toBe('sess_123'); + }); + + mocks.getSession.mockClear(); + + act(() => { + notifyNativeSessionChanged({ + sessionId: null, + user: null, + }); + }); + + expect(result.current.isSignedIn).toBe(false); + expect(result.current.sessionId).toBeNull(); + expect(result.current.user).toBeNull(); + expect(mocks.getSession).not.toHaveBeenCalled(); + }); + test('removes the native session listener on unmount', async () => { mocks.getSession.mockResolvedValue(null); diff --git a/packages/expo/src/hooks/nativeSessionEvents.ts b/packages/expo/src/hooks/nativeSessionEvents.ts index 67b4f8b59f4..c79daa2217c 100644 --- a/packages/expo/src/hooks/nativeSessionEvents.ts +++ b/packages/expo/src/hooks/nativeSessionEvents.ts @@ -1,4 +1,18 @@ -type NativeSessionListener = () => void; +export interface NativeSessionSnapshot { + sessionId?: string | null; + session?: { id?: string | null } | null; + user?: { + id: string; + firstName?: string | null; + lastName?: string | null; + imageUrl?: string | null; + primaryEmailAddress?: string | null; + primaryPhoneNumber?: string | null; + } | null; + clientToken?: string | null; +} + +type NativeSessionListener = (snapshot?: NativeSessionSnapshot) => void; const listeners = new Set(); @@ -10,6 +24,6 @@ export function addNativeSessionListener(listener: NativeSessionListener): () => }; } -export function notifyNativeSessionChanged(): void { - listeners.forEach(listener => listener()); +export function notifyNativeSessionChanged(snapshot?: NativeSessionSnapshot): void { + listeners.forEach(listener => listener(snapshot)); } diff --git a/packages/expo/src/hooks/useNativeClientEvents.ts b/packages/expo/src/hooks/useNativeClientEvents.ts index 03b4fcfd3e8..c60b234164b 100644 --- a/packages/expo/src/hooks/useNativeClientEvents.ts +++ b/packages/expo/src/hooks/useNativeClientEvents.ts @@ -2,11 +2,12 @@ import { useEffect, useState } from 'react'; import { NativeEventEmitter, Platform } from 'react-native'; import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; +import { notifyNativeSessionChanged, type NativeSessionSnapshot } from './nativeSessionEvents'; /** * Local marker for a native client event. */ -interface NativeClientEvent { +export interface NativeClientEvent extends NativeSessionSnapshot { issuedAt: number; } @@ -19,7 +20,10 @@ type RefreshClientEventSubscription = { }; type RefreshClientEventEmitter = { - addListener: (eventName: 'refreshClient', listener: () => void) => RefreshClientEventSubscription; + addListener: ( + eventName: 'refreshClient', + listener: (snapshot?: NativeSessionSnapshot) => void, + ) => RefreshClientEventSubscription; }; /** @@ -41,8 +45,9 @@ export function useNativeClientEvents(): UseNativeClientEventsReturn { ? (ClerkExpo as RefreshClientEventEmitter) : (new NativeEventEmitter(ClerkExpo) as RefreshClientEventEmitter); - subscription = eventEmitter.addListener('refreshClient', () => { - setNativeClientEvent({ issuedAt: Date.now() }); + subscription = eventEmitter.addListener('refreshClient', snapshot => { + notifyNativeSessionChanged(snapshot); + setNativeClientEvent({ issuedAt: Date.now(), ...snapshot }); }); } catch (error) { if (__DEV__) { diff --git a/packages/expo/src/hooks/useNativeSession.ts b/packages/expo/src/hooks/useNativeSession.ts index 46478f81a19..add1826906a 100644 --- a/packages/expo/src/hooks/useNativeSession.ts +++ b/packages/expo/src/hooks/useNativeSession.ts @@ -1,18 +1,14 @@ import { useCallback, useEffect, useState } from 'react'; import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; -import { addNativeSessionListener } from './nativeSessionEvents'; +import { addNativeSessionListener, type NativeSessionSnapshot } from './nativeSessionEvents'; + +type NativeSessionUser = NonNullable; // Native session data structure (normalized) interface NativeSessionData { sessionId?: string; - user?: { - id: string; - firstName?: string; - lastName?: string; - imageUrl?: string; - primaryEmailAddress?: string; - }; + user?: NativeSessionUser; } // Raw result from the native module (may vary by platform) @@ -89,6 +85,13 @@ export function useNativeSession(): UseNativeSessionReturn { const [sessionId, setSessionId] = useState(null); const [user, setUser] = useState(null); + const applySnapshot = useCallback((snapshot: NativeSessionSnapshot | NativeSessionRawResult | null) => { + const id = snapshot?.sessionId ?? snapshot?.session?.id ?? null; + setSessionId(id); + setUser(snapshot?.user ?? null); + setIsLoading(false); + }, []); + const refresh = useCallback(async () => { if (!isNativeSupported || !ClerkExpo?.getSession) { setIsLoading(false); @@ -98,10 +101,7 @@ export function useNativeSession(): UseNativeSessionReturn { try { setIsLoading(true); const result = (await ClerkExpo.getSession()) as NativeSessionRawResult | null; - // Normalize: iOS returns { sessionId }, Android returns { session: { id } } - const id = result?.sessionId ?? result?.session?.id ?? null; - setSessionId(id); - setUser(result?.user ?? null); + applySnapshot(result); } catch (error) { if (__DEV__) { console.error('[useNativeSession] Error fetching native session:', error); @@ -111,7 +111,7 @@ export function useNativeSession(): UseNativeSessionReturn { } finally { setIsLoading(false); } - }, []); + }, [applySnapshot]); // Check native session on mount useEffect(() => { @@ -119,10 +119,15 @@ export function useNativeSession(): UseNativeSessionReturn { }, [refresh]); useEffect(() => { - return addNativeSessionListener(() => { + return addNativeSessionListener(snapshot => { + if (snapshot) { + applySnapshot(snapshot); + return; + } + void refresh(); }); - }, [refresh]); + }, [applySnapshot, refresh]); return { isAvailable: isNativeSupported && !!ClerkExpo, diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 69c97c059eb..e71ad85fb3e 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -8,8 +8,8 @@ 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 { notifyNativeSessionChanged, type NativeSessionSnapshot } from '../hooks/nativeSessionEvents'; +import { type NativeClientEvent, useNativeClientEvents } from '../hooks/useNativeClientEvents'; import { tokenCache as defaultTokenCache } from '../token-cache'; import { ClerkExpoModule as NativeClerkModule } from '../utils/native-module'; import { isNative, isWeb } from '../utils/runtime'; @@ -56,6 +56,8 @@ const SDK_METADATA = { }; const tokenCacheReadTimeoutMs = 1_000; +const clientTokenPollIntervalMs = 100; +const clientTokenAvailabilityTimeoutMs = 3_000; type SyncableClerkInstance = { addListener?: (listener: (payload?: unknown) => void, options?: { skipInitialEmit?: boolean }) => () => void; @@ -67,24 +69,74 @@ type SyncableClerkInstance = { __internal_reloadInitialResources?: () => void | Promise; }; -async function waitForNativeClientToken(): Promise { +type NativeSessionResult = { + sessionId?: string | null; + session?: { id?: string | null } | null; +} | null; + +type NativeRefreshFromJsOptions = { + signOutNative?: boolean; + signOutSessionId?: string | null; + waitForToken: boolean; +}; + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function readNativeSessionId(): Promise { + const ClerkExpo = NativeClerkModule; + if (!ClerkExpo?.getSession) { + return undefined; + } + + const nativeSession = (await ClerkExpo.getSession()) as NativeSessionResult; + return nativeSession?.sessionId ?? nativeSession?.session?.id ?? null; +} + +function getNativeSessionIdFromSnapshot(snapshot: NativeSessionSnapshot | null | undefined): string | null | undefined { + if (!snapshot || (!('sessionId' in snapshot) && !('session' in snapshot))) { + return undefined; + } + + return snapshot.sessionId ?? snapshot.session?.id ?? null; +} + +function getNativeClientTokenFromSnapshot( + snapshot: NativeSessionSnapshot | null | undefined, +): string | null | undefined { + if (!snapshot || !('clientToken' in snapshot)) { + return undefined; + } + + return snapshot.clientToken ?? null; +} + +async function readNativeClientToken({ waitForToken }: { waitForToken: boolean }): Promise { const ClerkExpo = NativeClerkModule; if (!ClerkExpo?.getClientToken) { return null; } - const maxAttempts = 30; - const intervalMs = 100; + const startedAt = Date.now(); - for (let attempt = 0; attempt < maxAttempts; attempt++) { + do { const nativeClientToken = await ClerkExpo.getClientToken(); if (nativeClientToken) { return nativeClientToken; } - await new Promise(resolve => setTimeout(resolve, intervalMs)); - } - return null; + if (!waitForToken) { + return null; + } + + const remainingMs = clientTokenAvailabilityTimeoutMs - (Date.now() - startedAt); + if (remainingMs <= 0) { + return null; + } + + await delay(Math.min(clientTokenPollIntervalMs, remainingMs)); + } while (true); } async function syncClientTokenToCache(tokenCache: TokenCache | undefined, clientToken: string | null): Promise { @@ -128,30 +180,64 @@ async function readCachedClientToken({ tokenCache: TokenCache | undefined; waitForToken: boolean; }): Promise { - const maxAttempts = waitForToken ? 30 : 1; - const intervalMs = 100; + const startedAt = Date.now(); - for (let attempt = 0; attempt < maxAttempts; attempt++) { + do { const token = await getCachedClientToken(tokenCache); if (token || !waitForToken) { return token; } - await new Promise(resolve => setTimeout(resolve, intervalMs)); - } - return null; + const remainingMs = clientTokenAvailabilityTimeoutMs - (Date.now() - startedAt); + if (remainingMs <= 0) { + return null; + } + + await delay(Math.min(clientTokenPollIntervalMs, remainingMs)); + } while (true); } async function syncNativeClientToJs({ clerkInstance, clearMissingNativeToken, + nativeClientEvent, + skipNextJsSignOutRef, tokenCache, }: { clerkInstance: SyncableClerkInstance; clearMissingNativeToken: boolean; + nativeClientEvent?: NativeClientEvent | null; + skipNextJsSignOutRef?: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { - const nativeClientToken = await waitForNativeClientToken(); + const nativeSessionIdFromEvent = clearMissingNativeToken + ? getNativeSessionIdFromSnapshot(nativeClientEvent) + : undefined; + const nativeSessionId = + nativeSessionIdFromEvent !== undefined + ? nativeSessionIdFromEvent + : clearMissingNativeToken + ? await readNativeSessionId() + : undefined; + let clearedJsSession = false; + + if (nativeSessionId === null && clerkInstance.session?.id && typeof clerkInstance.setActive === 'function') { + if (skipNextJsSignOutRef) { + skipNextJsSignOutRef.current = true; + } + await clerkInstance.setActive({ session: null }); + clearedJsSession = true; + } + + const nativeClientTokenFromEvent = clearMissingNativeToken + ? getNativeClientTokenFromSnapshot(nativeClientEvent) + : undefined; + const nativeClientToken = + nativeClientTokenFromEvent !== undefined + ? nativeClientTokenFromEvent + : await readNativeClientToken({ + waitForToken: !clearMissingNativeToken || !hasActiveJsSession(clerkInstance), + }); const effectiveTokenCache = tokenCache ?? defaultTokenCache; if (!nativeClientToken && !clearMissingNativeToken) { @@ -163,8 +249,9 @@ async function syncNativeClientToJs({ await clerkInstance.__internal_reloadInitialResources(); } - const nativeActiveSessionId = clerkInstance.client?.lastActiveSessionId; - const jsActiveSessionId = clerkInstance.session?.id; + const nativeActiveSessionId = + nativeSessionId !== undefined ? nativeSessionId : clerkInstance.client?.lastActiveSessionId; + const jsActiveSessionId = clearedJsSession ? null : clerkInstance.session?.id; if ( nativeActiveSessionId && @@ -173,6 +260,9 @@ async function syncNativeClientToJs({ ) { await clerkInstance.setActive({ session: nativeActiveSessionId }); } else if (!nativeActiveSessionId && jsActiveSessionId && typeof clerkInstance.setActive === 'function') { + if (skipNextJsSignOutRef) { + skipNextJsSignOutRef.current = true; + } await clerkInstance.setActive({ session: null }); } } @@ -188,63 +278,95 @@ function NativeClientSync({ clerkInstance, isSyncingNativeClientToJsRef, publishableKey, + skipNextJsSignOutRef, tokenCache, }: { clerkInstance: SyncableClerkInstance | null | undefined; isSyncingNativeClientToJsRef: MutableRefObject; publishableKey: string; + skipNextJsSignOutRef: MutableRefObject; tokenCache: TokenCache | undefined; }): null { const { isLoaded, sessionId } = useAuth(); const isRefreshingNativeFromJsRef = useRef(false); - const pendingNativeRefreshWaitForTokenRef = useRef(null); + const hasSeenLoadedJsAuthRef = useRef(isLoaded); + const previousJsSessionIdRef = useRef(isLoaded ? (sessionId ?? null) : null); + const pendingNativeRefreshRef = useRef(null); + const nativeRefreshGenerationRef = useRef(0); // Use the provided tokenCache, falling back to the default SecureStore cache const effectiveTokenCache = tokenCache ?? defaultTokenCache; const queueNativeRefreshFromJs = useCallback( - (waitForToken: boolean): void => { - if (isSyncingNativeClientToJsRef.current && !waitForToken) { + (options: NativeRefreshFromJsOptions): void => { + if (isSyncingNativeClientToJsRef.current && !options.waitForToken && !options.signOutNative) { return; } - if (isRefreshingNativeFromJsRef.current) { - pendingNativeRefreshWaitForTokenRef.current = - pendingNativeRefreshWaitForTokenRef.current === true || waitForToken; + if (isRefreshingNativeFromJsRef.current && !options.signOutNative) { + pendingNativeRefreshRef.current = options; return; } + const initialGeneration = nativeRefreshGenerationRef.current + 1; + nativeRefreshGenerationRef.current = initialGeneration; + if (options.signOutNative) { + pendingNativeRefreshRef.current = null; + } isRefreshingNativeFromJsRef.current = true; - const refreshNativeFromJsClient = async (shouldWaitForToken: boolean): Promise => { + const refreshNativeFromJsClient = async ( + { signOutNative, signOutSessionId, waitForToken }: NativeRefreshFromJsOptions, + generation: number, + ): Promise => { const ClerkExpo = NativeClerkModule; - if (!ClerkExpo) { + if (!ClerkExpo || generation !== nativeRefreshGenerationRef.current) { + return; + } + + if (signOutNative) { + await ClerkExpo.signOut?.(signOutSessionId ?? null); return; } const bearerToken = await readCachedClientToken({ tokenCache: effectiveTokenCache, - waitForToken: shouldWaitForToken, + waitForToken, }); + if (generation !== nativeRefreshGenerationRef.current) { + return; + } + if (bearerToken) { // configure writes the token and refreshes native client state. await ClerkExpo.configure(publishableKey, bearerToken); } else { const nativeClientToken = (await ClerkExpo.getClientToken?.()) ?? null; + if (generation !== nativeRefreshGenerationRef.current) { + return; + } + if (nativeClientToken) { // No JS token to push, but native has a stored client token to reload. await ClerkExpo.refreshClient(); } } - notifyNativeSessionChanged(); }; + let latestRunGeneration = initialGeneration; + void (async () => { - let waitForPendingToken = waitForToken; + let pendingOptions = options; + let generation = initialGeneration; do { - pendingNativeRefreshWaitForTokenRef.current = null; - await refreshNativeFromJsClient(waitForPendingToken); - waitForPendingToken = pendingNativeRefreshWaitForTokenRef.current ?? false; - } while (pendingNativeRefreshWaitForTokenRef.current !== null); + latestRunGeneration = generation; + pendingNativeRefreshRef.current = null; + await refreshNativeFromJsClient(pendingOptions, generation); + pendingOptions = pendingNativeRefreshRef.current ?? { waitForToken: false }; + if (pendingNativeRefreshRef.current !== null) { + generation = nativeRefreshGenerationRef.current + 1; + nativeRefreshGenerationRef.current = generation; + } + } while (pendingNativeRefreshRef.current !== null); })() .catch((error: unknown) => { if (__DEV__) { @@ -252,7 +374,9 @@ function NativeClientSync({ } }) .finally(() => { - isRefreshingNativeFromJsRef.current = false; + if (latestRunGeneration === nativeRefreshGenerationRef.current) { + isRefreshingNativeFromJsRef.current = false; + } }); }, [effectiveTokenCache, isSyncingNativeClientToJsRef, publishableKey], @@ -263,8 +387,29 @@ function NativeClientSync({ return; } - queueNativeRefreshFromJs(Boolean(sessionId)); - }, [isLoaded, queueNativeRefreshFromJs, sessionId]); + const previousSessionId = previousJsSessionIdRef.current; + const hasSeenLoadedJsAuth = hasSeenLoadedJsAuthRef.current; + hasSeenLoadedJsAuthRef.current = true; + previousJsSessionIdRef.current = sessionId ?? null; + + if (!hasSeenLoadedJsAuth) { + if (sessionId) { + queueNativeRefreshFromJs({ waitForToken: true }); + } + return; + } + + if (sessionId) { + queueNativeRefreshFromJs({ waitForToken: true }); + } else if (previousSessionId) { + if (skipNextJsSignOutRef.current) { + skipNextJsSignOutRef.current = false; + return; + } + + queueNativeRefreshFromJs({ signOutNative: true, signOutSessionId: previousSessionId, waitForToken: false }); + } + }, [isLoaded, queueNativeRefreshFromJs, sessionId, skipNextJsSignOutRef]); useEffect(() => { if (!clerkInstance || typeof clerkInstance.addListener !== 'function') { @@ -273,7 +418,9 @@ function NativeClientSync({ return clerkInstance.addListener( () => { - queueNativeRefreshFromJs(hasActiveJsSession(clerkInstance)); + if (hasActiveJsSession(clerkInstance)) { + queueNativeRefreshFromJs({ waitForToken: true }); + } }, { skipInitialEmit: true }, ); @@ -430,6 +577,7 @@ export function ClerkProvider(props: ClerkProviderProps(props: ClerkProviderProps(props: ClerkProviderProps(props: ClerkProviderProps )} diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx index f87b7a32395..8d0b4f0405b 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx @@ -8,10 +8,12 @@ import { ClerkProvider } from '../ClerkProvider'; const mocks = vi.hoisted(() => { return { configure: vi.fn(), + getSession: vi.fn(), getClientToken: vi.fn(), nativeClientEvent: null as unknown, notifyNativeSessionChanged: vi.fn(), refreshClient: vi.fn(), + signOut: vi.fn(), tokenCache: { clearToken: vi.fn(), getToken: vi.fn(), @@ -96,8 +98,10 @@ vi.mock('../../specs/NativeClerkModule', () => { return { default: { configure: mocks.configure, + getSession: mocks.getSession, getClientToken: mocks.getClientToken, refreshClient: mocks.refreshClient, + signOut: mocks.signOut, }, }; }); @@ -120,7 +124,9 @@ describe('ClerkProvider native session notifications', () => { vi.clearAllMocks(); mocks.nativeClientEvent = null; mocks.configure.mockResolvedValue(undefined); + mocks.getSession.mockResolvedValue({ sessionId: 'sess_native' }); mocks.getClientToken.mockResolvedValue('native-client-token'); + mocks.signOut.mockResolvedValue(undefined); mocks.tokenCache.getToken.mockResolvedValue('client-token'); mocks.tokenCache.saveToken.mockResolvedValue(undefined); mocks.tokenCache.clearToken.mockResolvedValue(undefined); @@ -169,9 +175,15 @@ describe('ClerkProvider native session notifications', () => { mocks.clerkInstance.client.lastActiveSessionId = 'sess_native'; mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); mocks.clerkInstance.setActive.mockClear(); + mocks.getClientToken.mockClear(); + mocks.getSession.mockClear(); mocks.notifyNativeSessionChanged.mockClear(); - mocks.nativeClientEvent = { type: 'refreshClient' }; + mocks.nativeClientEvent = { + issuedAt: 1, + sessionId: 'sess_native', + clientToken: 'native-client-token', + }; rerender( { await waitFor(() => { expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); }); + expect(mocks.getClientToken).not.toHaveBeenCalled(); + expect(mocks.getSession).not.toHaveBeenCalled(); expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: 'sess_native' }); - expect(mocks.notifyNativeSessionChanged).toHaveBeenCalledTimes(1); + expect(mocks.notifyNativeSessionChanged).not.toHaveBeenCalled(); }); test('clears the JS active session after native signs out', async () => { @@ -204,12 +218,20 @@ describe('ClerkProvider native session notifications', () => { }); expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled(); + mocks.getClientToken.mockResolvedValue(null); + mocks.getSession.mockResolvedValue(null); + mocks.getClientToken.mockClear(); + mocks.getSession.mockClear(); mocks.clerkInstance.client.lastActiveSessionId = null; mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); mocks.clerkInstance.setActive.mockClear(); mocks.notifyNativeSessionChanged.mockClear(); - mocks.nativeClientEvent = { type: 'refreshClient' }; + mocks.nativeClientEvent = { + issuedAt: 1, + sessionId: null, + clientToken: null, + }; rerender( { ); await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); + expect(mocks.tokenCache.clearToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY); }); + expect(mocks.getClientToken).not.toHaveBeenCalled(); + expect(mocks.getSession).not.toHaveBeenCalled(); expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: null }); - expect(mocks.notifyNativeSessionChanged).toHaveBeenCalledTimes(1); + expect(mocks.notifyNativeSessionChanged).not.toHaveBeenCalled(); + }); + + test('clears the JS active session before waiting for initial resources after native sign-out', async () => { + mocks.clerkInstance.client.lastActiveSessionId = 'sess_js'; + mocks.clerkInstance.session.id = 'sess_js'; + mocks.authState.sessionId = 'sess_js'; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.notifyNativeSessionChanged).toHaveBeenCalled(); + }); + + mocks.getClientToken.mockResolvedValue('native-client-token'); + mocks.getSession.mockResolvedValue(null); + mocks.getClientToken.mockClear(); + mocks.getSession.mockClear(); + mocks.clerkInstance.__internal_reloadInitialResources.mockImplementation(() => new Promise(() => {})); + mocks.clerkInstance.setActive.mockClear(); + mocks.notifyNativeSessionChanged.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + sessionId: null, + clientToken: 'native-client-token', + }; + rerender( + , + ); + + await waitFor(() => { + expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: null }); + }); + expect(mocks.getSession).not.toHaveBeenCalled(); }); test('does not refresh native from JS when neither side has a client token', async () => { @@ -296,6 +362,7 @@ describe('ClerkProvider native session notifications', () => { mocks.configure.mockClear(); mocks.notifyNativeSessionChanged.mockClear(); mocks.refreshClient.mockClear(); + mocks.signOut.mockClear(); mocks.tokenCache.getToken.mockResolvedValue(null); mocks.authState.sessionId = null; @@ -307,9 +374,10 @@ describe('ClerkProvider native session notifications', () => { ); await waitFor(() => { - expect(mocks.refreshClient).toHaveBeenCalled(); + expect(mocks.signOut).toHaveBeenCalledWith('sess_js'); }); expect(mocks.configure).not.toHaveBeenCalled(); - expect(mocks.notifyNativeSessionChanged).toHaveBeenCalled(); + expect(mocks.refreshClient).not.toHaveBeenCalled(); + expect(mocks.notifyNativeSessionChanged).not.toHaveBeenCalled(); }); }); diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index 1ffb278809a..4f8c566e2c7 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -10,6 +10,7 @@ interface Spec { getSession(): Promise; getClientToken(): Promise; refreshClient(): Promise; + signOut(sessionId: string | null): Promise; removeListeners?(count: number): void; } diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index 9600dea3835..1e560c53544 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -10,6 +10,7 @@ export interface Spec extends TurboModule { getSession(): Promise; getClientToken(): Promise; refreshClient(): Promise; + signOut(sessionId: string | null): Promise; // Required by NativeEventEmitter for internal native client refresh events. // This is not part of the public @clerk/expo API. removeListeners(count: number): void; From 447f9345742a59c35dc96b97ddeffc071c7794b7 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:37:14 -0400 Subject: [PATCH 8/9] fix(expo): prevent native sync feedback loops --- packages/expo/src/provider/ClerkProvider.tsx | 47 ++++++------ .../ClerkProvider.nativeSession.test.tsx | 73 ++++++++++++++++++- 2 files changed, 97 insertions(+), 23 deletions(-) diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index e71ad85fb3e..96977f0693f 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -201,13 +201,13 @@ async function syncNativeClientToJs({ clerkInstance, clearMissingNativeToken, nativeClientEvent, - skipNextJsSignOutRef, + skipNextJsAuthSyncRef, tokenCache, }: { clerkInstance: SyncableClerkInstance; clearMissingNativeToken: boolean; nativeClientEvent?: NativeClientEvent | null; - skipNextJsSignOutRef?: MutableRefObject; + skipNextJsAuthSyncRef?: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { const nativeSessionIdFromEvent = clearMissingNativeToken @@ -222,8 +222,8 @@ async function syncNativeClientToJs({ let clearedJsSession = false; if (nativeSessionId === null && clerkInstance.session?.id && typeof clerkInstance.setActive === 'function') { - if (skipNextJsSignOutRef) { - skipNextJsSignOutRef.current = true; + if (skipNextJsAuthSyncRef) { + skipNextJsAuthSyncRef.current = true; } await clerkInstance.setActive({ session: null }); clearedJsSession = true; @@ -258,10 +258,13 @@ async function syncNativeClientToJs({ nativeActiveSessionId !== jsActiveSessionId && typeof clerkInstance.setActive === 'function' ) { + if (skipNextJsAuthSyncRef) { + skipNextJsAuthSyncRef.current = true; + } await clerkInstance.setActive({ session: nativeActiveSessionId }); } else if (!nativeActiveSessionId && jsActiveSessionId && typeof clerkInstance.setActive === 'function') { - if (skipNextJsSignOutRef) { - skipNextJsSignOutRef.current = true; + if (skipNextJsAuthSyncRef) { + skipNextJsAuthSyncRef.current = true; } await clerkInstance.setActive({ session: null }); } @@ -278,13 +281,13 @@ function NativeClientSync({ clerkInstance, isSyncingNativeClientToJsRef, publishableKey, - skipNextJsSignOutRef, + skipNextJsAuthSyncRef, tokenCache, }: { clerkInstance: SyncableClerkInstance | null | undefined; isSyncingNativeClientToJsRef: MutableRefObject; publishableKey: string; - skipNextJsSignOutRef: MutableRefObject; + skipNextJsAuthSyncRef: MutableRefObject; tokenCache: TokenCache | undefined; }): null { const { isLoaded, sessionId } = useAuth(); @@ -298,10 +301,6 @@ function NativeClientSync({ const queueNativeRefreshFromJs = useCallback( (options: NativeRefreshFromJsOptions): void => { - if (isSyncingNativeClientToJsRef.current && !options.waitForToken && !options.signOutNative) { - return; - } - if (isRefreshingNativeFromJsRef.current && !options.signOutNative) { pendingNativeRefreshRef.current = options; return; @@ -399,17 +398,17 @@ function NativeClientSync({ return; } + if (skipNextJsAuthSyncRef.current) { + skipNextJsAuthSyncRef.current = false; + return; + } + if (sessionId) { queueNativeRefreshFromJs({ waitForToken: true }); } else if (previousSessionId) { - if (skipNextJsSignOutRef.current) { - skipNextJsSignOutRef.current = false; - return; - } - queueNativeRefreshFromJs({ signOutNative: true, signOutSessionId: previousSessionId, waitForToken: false }); } - }, [isLoaded, queueNativeRefreshFromJs, sessionId, skipNextJsSignOutRef]); + }, [isLoaded, queueNativeRefreshFromJs, sessionId, skipNextJsAuthSyncRef]); useEffect(() => { if (!clerkInstance || typeof clerkInstance.addListener !== 'function') { @@ -418,6 +417,10 @@ function NativeClientSync({ return clerkInstance.addListener( () => { + if (isSyncingNativeClientToJsRef.current) { + return; + } + if (hasActiveJsSession(clerkInstance)) { queueNativeRefreshFromJs({ waitForToken: true }); } @@ -577,7 +580,7 @@ export function ClerkProvider(props: ClerkProviderProps(props: ClerkProviderProps(props: ClerkProviderProps(props: ClerkProviderProps )} diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx index 8d0b4f0405b..faebfdc6549 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx @@ -35,6 +35,7 @@ const mocks = vi.hoisted(() => { isLoaded: true, sessionId: null as string | null, }, + clerkListener: undefined as (() => void) | undefined, }; }); @@ -130,7 +131,11 @@ describe('ClerkProvider native session notifications', () => { mocks.tokenCache.getToken.mockResolvedValue('client-token'); mocks.tokenCache.saveToken.mockResolvedValue(undefined); mocks.tokenCache.clearToken.mockResolvedValue(undefined); - mocks.clerkInstance.addListener.mockReturnValue(vi.fn()); + mocks.clerkListener = undefined; + mocks.clerkInstance.addListener.mockImplementation(listener => { + mocks.clerkListener = listener; + return vi.fn(); + }); mocks.clerkInstance.client.lastActiveSessionId = 'sess_native'; mocks.clerkInstance.session.id = null; mocks.authState.isLoaded = true; @@ -199,6 +204,19 @@ describe('ClerkProvider native session notifications', () => { expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: 'sess_native' }); expect(mocks.notifyNativeSessionChanged).not.toHaveBeenCalled(); + + mocks.configure.mockClear(); + mocks.authState.sessionId = 'sess_native'; + rerender( + , + ); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mocks.configure).not.toHaveBeenCalled(); }); test('clears the JS active session after native signs out', async () => { @@ -247,6 +265,59 @@ describe('ClerkProvider native session notifications', () => { expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: null }); expect(mocks.notifyNativeSessionChanged).not.toHaveBeenCalled(); + + mocks.signOut.mockClear(); + mocks.authState.sessionId = null; + rerender( + , + ); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mocks.signOut).not.toHaveBeenCalled(); + }); + + test('does not refresh native from JS while applying a native event', async () => { + mocks.tokenCache.getToken.mockResolvedValue(null); + mocks.getClientToken.mockResolvedValue(null); + mocks.clerkInstance.client.lastActiveSessionId = null; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.notifyNativeSessionChanged).toHaveBeenCalled(); + }); + + mocks.configure.mockClear(); + mocks.clerkInstance.setActive.mockImplementation(async () => { + mocks.clerkListener?.(); + }); + mocks.clerkInstance.client.lastActiveSessionId = 'sess_native'; + mocks.nativeClientEvent = { + issuedAt: 1, + sessionId: 'sess_native', + clientToken: 'native-client-token', + }; + rerender( + , + ); + + await waitFor(() => { + expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: 'sess_native' }); + }); + + expect(mocks.configure).not.toHaveBeenCalled(); }); test('clears the JS active session before waiting for initial resources after native sign-out', async () => { From e65c67146063211cbbfabc3261b47c772fd028d9 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:46:53 -0400 Subject: [PATCH 9/9] fix(expo): emit native sign-out immediately --- packages/expo/ios/ClerkNativeBridge.swift | 25 +++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 682ad16304e..608a1956f5b 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -140,13 +140,16 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { case .sessionChanged(_, _), .signInCompleted(_), .signUpCompleted(_): emitClerkNativeRefreshClient(Self.authStatePayload()) case .signedOut(_), .accountDeleted: - do { - _ = try await Clerk.shared.refreshClient() - } catch { - // The auth event still represents the latest user action; emit below so - // JS consumers can clear stale state even if the follow-up refresh fails. + emitClerkNativeRefreshClient(Self.signedOutAuthStatePayload()) + Swift.Task { @MainActor in + do { + _ = try await Clerk.shared.refreshClient() + } catch { + // The auth event still represents the latest user action; emit below so + // JS consumers can clear stale state even if the follow-up refresh fails. + } + emitClerkNativeRefreshClient(Self.authStatePayload()) } - emitClerkNativeRefreshClient(Self.authStatePayload()) case .signInNeedsContinuation(_), .signUpNeedsContinuation(_), .tokenRefreshed(_): break } @@ -182,6 +185,16 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { return payload } + @MainActor + private static func signedOutAuthStatePayload() -> [String: Any] { + return [ + "sessionId": NSNull(), + "clientToken": Clerk.shared.deviceToken ?? NSNull(), + "session": NSNull(), + "user": NSNull(), + ] + } + @MainActor private static func syncTokenState(bearerToken: String?) async throws -> Bool { guard let token = bearerToken, !token.isEmpty else {