From 9168040280b74500080a6e27f3b327133f9c3730 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 10 Mar 2026 12:46:45 -0700 Subject: [PATCH 1/5] feat(expo): add two-way JS/native session sync for expo native components When users authenticate via the JS SDK (custom sign-in forms, useSignIn, etc.) instead of through native AuthView, the native SDK doesn't know about the session. This causes native components like UserButton and UserProfileView to show empty/error states. Changes: - ClerkProvider: skip native configure when no bearer token to prevent creating anonymous native clients that conflict with later token sync - ClerkProvider: add NativeSessionSync component that pushes JS SDK bearer token to native when user signs in via JS - ClerkViewFactory (iOS): clear stale cached client/environment from keychain when device token changes, preventing 400 API errors from mismatched client IDs - ClerkViewFactory (iOS): add readNativeDeviceToken and clearCachedClerkData helpers for safe keychain management - ClerkViewFactory (iOS): track configure state with static flag to avoid accessing Clerk.shared before SDK initialization - UserButton: sync JS bearer token to native before presenting profile modal - useUserProfileModal: sync JS bearer token to native before presenting --- packages/expo/ios/ClerkExpoModule.swift | 31 ------ packages/expo/ios/ClerkViewFactory.swift | 97 +++++++++++++++---- .../expo/src/hooks/useUserProfileModal.ts | 23 ++++- packages/expo/src/native/UserButton.tsx | 31 +++--- packages/expo/src/provider/ClerkProvider.tsx | 72 ++++++++++++++ 5 files changed, 188 insertions(+), 66 deletions(-) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index eabfb44d685..6099b665a04 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -170,37 +170,6 @@ class ClerkExpoModule: RCTEventEmitter { } } - // MARK: - getClientToken - - @objc func getClientToken(_ resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { - // Use a custom keychain service if configured in Info.plist (for extension apps - // sharing a keychain group). Falls back to the main bundle identifier. - let keychainService: String = { - if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty { - return custom - } - return Bundle.main.bundleIdentifier ?? "" - }() - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: "clerkDeviceToken", - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - if status == errSecSuccess, let data = result as? Data { - resolve(String(data: data, encoding: .utf8)) - } else { - resolve(nil) - } - } - // MARK: - signOut @objc func signOut(_ resolve: @escaping RCTPromiseResolveBlock, diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index d4a80aa6bc6..2e8428221d6 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -16,6 +16,7 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { private static let clerkLoadMaxAttempts = 30 private static let clerkLoadIntervalNs: UInt64 = 100_000_000 + private static var clerkConfigured = false private init() {} @@ -39,11 +40,30 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { // 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 { - Self.writeNativeDeviceTokenIfNeeded(token) + let existingToken = Self.readNativeDeviceToken() + Self.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 { + Self.clearCachedClerkData() + } } else { Self.syncJSTokenToNativeKeychainIfNeeded() } + // 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 a no-op on subsequent calls, so we use refreshClient(). + if Self.clerkConfigured, let token = bearerToken, !token.isEmpty { + _ = try? await Clerk.shared.refreshClient() + return + } + + Self.clerkConfigured = true Clerk.configure(publishableKey: publishableKey) // Wait for Clerk to finish loading (cached data + API refresh). @@ -106,14 +126,48 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { SecItemAdd(writeQuery as CFDictionary, nil) } - /// Writes the provided bearer token as the native SDK's device token, - /// but only if the native SDK doesn't already have one. - private static func writeNativeDeviceTokenIfNeeded(_ token: String) { + /// Reads the native device token from keychain, if present. + private static func readNativeDeviceToken() -> String? { + guard let service = keychainService, !service.isEmpty else { return nil } + + var result: CFTypeRef? + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "clerkDeviceToken", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + /// 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() { + guard let service = keychainService, !service.isEmpty else { return } + + for key in ["cachedClient", "cachedEnvironment"] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + } + } + + /// 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) { guard let service = keychainService, !service.isEmpty else { return } let nativeTokenKey = "clerkDeviceToken" + guard let tokenData = token.data(using: .utf8) else { return } - // Check if native SDK already has a device token — don't overwrite + // Check if native SDK already has a device token let checkQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -121,20 +175,29 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { kSecReturnData as String: false, kSecMatchLimit as String: kSecMatchLimitOne, ] + if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess { - return + // Update the existing token + let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: nativeTokenKey, + ] + let updateAttributes: [String: Any] = [ + kSecValueData as String: tokenData, + ] + SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary) + } else { + // Write a new token + let writeQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: nativeTokenKey, + kSecValueData as String: tokenData, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + SecItemAdd(writeQuery as CFDictionary, nil) } - - // Write the provided token as native device token - guard let tokenData = token.data(using: .utf8) else { return } - let writeQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: nativeTokenKey, - kSecValueData as String: tokenData, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - ] - SecItemAdd(writeQuery as CFDictionary, nil) } public func createAuthViewController( diff --git a/packages/expo/src/hooks/useUserProfileModal.ts b/packages/expo/src/hooks/useUserProfileModal.ts index d97b8c35b28..1b0f2410be1 100644 --- a/packages/expo/src/hooks/useUserProfileModal.ts +++ b/packages/expo/src/hooks/useUserProfileModal.ts @@ -1,6 +1,8 @@ -import { useClerk } from '@clerk/react'; +import { useClerk, useUser } from '@clerk/react'; import { useCallback, useRef } from 'react'; +import { CLERK_CLIENT_JWT_KEY } from '../constants'; +import { tokenCache } from '../token-cache'; import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; // Raw result from the native module (may vary by platform) @@ -53,6 +55,7 @@ export interface UseUserProfileModalReturn { */ export function useUserProfileModal(): UseUserProfileModalReturn { const clerk = useClerk(); + const { user } = useUser(); const presentingRef = useRef(false); const presentUserProfile = useCallback(async () => { @@ -66,6 +69,20 @@ export function useUserProfileModal(): UseUserProfileModalReturn { presentingRef.current = true; try { + // If native doesn't have a session but JS does (e.g. user signed in via custom form), + // sync the JS SDK's bearer token to native and wait for it before presenting. + if (user && ClerkExpo?.getSession && ClerkExpo?.configure) { + const preCheck = (await ClerkExpo.getSession()) as NativeSessionResult | null; + const hasSession = !!(preCheck?.sessionId || preCheck?.session?.id); + + if (!hasSession) { + const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + if (bearerToken) { + await ClerkExpo.configure(clerk.publishableKey, bearerToken); + } + } + } + await ClerkExpo.presentUserProfile({ dismissable: true, }); @@ -97,15 +114,13 @@ export function useUserProfileModal(): UseUserProfileModalReturn { } } } catch (error) { - // Dismissal resolves successfully with { dismissed: true }, so reaching - // here means a real native error (E_NOT_INITIALIZED, E_CREATE_FAILED, E_NO_ROOT_VC). if (__DEV__) { console.error('[useUserProfileModal] presentUserProfile failed:', error); } } finally { presentingRef.current = false; } - }, [clerk]); + }, [clerk, user]); return { presentUserProfile, diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx index 045d3027080..bfdd9bceb05 100644 --- a/packages/expo/src/native/UserButton.tsx +++ b/packages/expo/src/native/UserButton.tsx @@ -2,6 +2,8 @@ import { useClerk, useUser } from '@clerk/react'; import { useEffect, useRef, useState } from 'react'; import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { CLERK_CLIENT_JWT_KEY } from '../constants'; +import { tokenCache } from '../token-cache'; import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; // Raw result from native module (may vary by platform) @@ -133,6 +135,20 @@ export function UserButton(_props: UserButtonProps) { presentingRef.current = true; try { + // If native doesn't have a session but JS does (e.g. user signed in via custom form), + // sync the JS SDK's bearer token to native and wait for it before presenting. + if (clerkUser && ClerkExpo?.getSession && ClerkExpo?.configure) { + const preCheck = (await ClerkExpo.getSession()) as NativeSessionResult | null; + const hasSession = !!(preCheck?.sessionId || preCheck?.session?.id); + + if (!hasSession) { + const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + if (bearerToken) { + await ClerkExpo.configure(clerk.publishableKey, bearerToken); + } + } + } + await ClerkExpo.presentUserProfile({ dismissable: true, }); @@ -161,25 +177,12 @@ export function UserButton(_props: UserButtonProps) { await clerk.signOut(); } catch (e) { if (__DEV__) { - console.warn('[UserButton] JS SDK signOut error, attempting reload:', e); - } - // Even if signOut throws, try to force reload to clear stale state - const clerkRecord = clerk as unknown as Record; - if (typeof clerkRecord.__internal_reloadInitialResources === 'function') { - try { - await (clerkRecord.__internal_reloadInitialResources as () => Promise)(); - } catch (reloadErr) { - if (__DEV__) { - console.warn('[UserButton] Best-effort reload failed:', reloadErr); - } - } + console.warn('[UserButton] JS SDK signOut error:', e); } } } } } catch (error) { - // Dismissal resolves successfully with { dismissed: true }, so reaching - // here means a real native error (E_NOT_INITIALIZED, E_CREATE_FAILED, E_NO_ROOT_VC). if (__DEV__) { console.error('[UserButton] presentUserProfile failed:', error); } diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 9a693773544..518788eee5f 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -1,6 +1,7 @@ import '../polyfills'; import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react'; +import { useAuth } from '@clerk/react'; import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal'; import { useEffect, useRef } from 'react'; import { Platform } from 'react-native'; @@ -52,6 +53,68 @@ const SDK_METADATA = { version: PACKAGE_VERSION, }; +/** + * Syncs JS SDK auth state to the native Clerk SDK. + * + * When a user authenticates via the JS SDK (custom sign-in forms, useSignIn, etc.) + * rather than through native ``, the native SDK doesn't know about the + * session. This component watches for JS auth state changes and pushes the bearer + * token to the native SDK so native components (UserButton, UserProfileView) work. + * + * Must be rendered inside `ClerkReactProvider` so `useAuth()` has access to context. + */ +function NativeSessionSync({ publishableKey }: { publishableKey: string }) { + const { isSignedIn } = useAuth(); + const hasSyncedRef = useRef(false); + + useEffect(() => { + if (!isSignedIn) { + hasSyncedRef.current = false; + return; + } + + if (hasSyncedRef.current) { + return; + } + + hasSyncedRef.current = true; + + const syncToNative = async () => { + try { + const ClerkExpo = NativeClerkModule; + if (!ClerkExpo?.configure || !ClerkExpo?.getSession) { + return; + } + + // Check if native already has a session (e.g. auth via AuthView or initial load) + const nativeSession = (await ClerkExpo.getSession()) as { + sessionId?: string; + session?: { id: string }; + } | null; + const hasNativeSession = !!(nativeSession?.sessionId || nativeSession?.session?.id); + + if (hasNativeSession) { + return; + } + + // Read the JS SDK's client JWT and push it to the native SDK + const bearerToken = (await defaultTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + if (bearerToken) { + await ClerkExpo.configure(publishableKey, bearerToken); + } + } catch (error) { + if (__DEV__) { + console.warn('[NativeSessionSync] Failed to sync JS session to native:', error); + } + } + }; + + void syncToNative(); + }, [isSignedIn, publishableKey]); + + return null; +} + export function ClerkProvider(props: ClerkProviderProps): JSX.Element { const { children, @@ -109,6 +172,14 @@ export function ClerkProvider(props: ClerkProviderProps(props: ClerkProviderProps + {isNative() && } {children} ); From 4461fa2d00aea67489e557e33f0a54e7793f0593 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 10 Mar 2026 15:25:19 -0700 Subject: [PATCH 2/5] =?UTF-8?q?feat(expo):=20add=20Android=202-way=20JS?= =?UTF-8?q?=E2=86=94native=20session=20sync=20for=20native=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When users authenticate via the JS SDK (custom sign-in forms) instead of native , the native Android SDK doesn't have the session. This causes and useUserProfileModal to show broken/empty profile modals. Changes: - ClerkExpoModule.kt: getSession() and signOut() now resolve gracefully when SDK is not initialized (matches iOS behavior), enabling NativeSessionSync to detect the missing session and call configure() - ClerkExpoModule.kt: configure() handles re-initialization when SDK is already initialized by writing bearer token to SharedPreferences and using type-based reflection to trigger a client refresh via reinitialize() - UserButton.tsx & useUserProfileModal.ts: Track whether native had a session before the profile modal opens, only sign out JS SDK if the session was actually lost during the modal (prevents false sign-out when native never had a session) - ClerkProvider.tsx (NativeSessionSync): Added debug logging for sync flow --- .../expo/modules/clerk/ClerkExpoModule.kt | 192 +++++++++++++++++- .../expo/src/hooks/useUserProfileModal.ts | 57 +++++- packages/expo/src/native/UserButton.tsx | 67 +++++- packages/expo/src/provider/ClerkProvider.tsx | 27 +++ 4 files changed, 322 insertions(+), 21 deletions(-) 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 f08753c21fe..b976106510e 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 @@ -14,6 +14,7 @@ import com.facebook.react.bridge.WritableNativeMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout @@ -63,6 +64,7 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun configure(pubKey: String, bearerToken: String?, promise: Promise) { + debugLog(TAG, "configure - START pubKey=${pubKey.take(20)}... bearerToken=${if (bearerToken != null) "present(${bearerToken.length} chars)" else "null"}") coroutineScope.launch { try { publishableKey = pubKey @@ -70,13 +72,45 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : // If the JS SDK has a bearer token, write it to the native SDK's // SharedPreferences so both SDKs share the same Clerk API client. if (!bearerToken.isNullOrEmpty()) { + debugLog(TAG, "configure - writing bearer token to SharedPreferences") reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) .edit() .putString("DEVICE_TOKEN", bearerToken) .apply() - debugLog(TAG, "configure - wrote JS bearer token to native SharedPreferences") + + // Verify write + val verified = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) + .getString("DEVICE_TOKEN", null) + debugLog(TAG, "configure - SharedPreferences verify: ${if (verified != null) "written(${verified.length} chars)" else "WRITE FAILED"}") } + debugLog(TAG, "configure - Clerk.isInitialized=${Clerk.isInitialized.value}") + + if (Clerk.isInitialized.value) { + debugLog(TAG, "configure - already initialized, session=${Clerk.session?.id}, user=${Clerk.user?.id}") + // Already initialized — force a client refresh so the SDK + // picks up the new device token from SharedPreferences. + debugLog(TAG, "configure - calling forceClientRefresh()") + forceClientRefresh() + + // Wait for session to appear with the new token (up to 5s) + try { + debugLog(TAG, "configure - waiting for session (up to 5s)...") + withTimeout(5_000L) { + Clerk.sessionFlow.first { it != null } + } + debugLog(TAG, "configure - session appeared! session=${Clerk.session?.id}, user=${Clerk.user?.id}") + } catch (_: TimeoutCancellationException) { + debugLog(TAG, "configure - session did not appear after force refresh (timeout)") + debugLog(TAG, "configure - post-timeout state: session=${Clerk.session?.id}, user=${Clerk.user?.id}, client=${Clerk.client}") + } + + promise.resolve(null) + return@launch + } + + // First-time initialization + debugLog(TAG, "configure - first-time init, calling Clerk.initialize()") Clerk.initialize(reactApplicationContext, pubKey) // Wait for initialization to complete with timeout @@ -84,6 +118,7 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : withTimeout(10_000L) { Clerk.isInitialized.first { it } } + debugLog(TAG, "configure - initialized! session=${Clerk.session?.id}, user=${Clerk.user?.id}") } catch (e: TimeoutCancellationException) { val initError = Clerk.initializationError.value val message = if (initError != null) { @@ -91,6 +126,7 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } else { "Clerk initialization timed out after 10 seconds" } + debugLog(TAG, "configure - TIMEOUT: $message") promise.reject("E_TIMEOUT", message) return@launch } @@ -98,16 +134,129 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : // Check for initialization errors val error = Clerk.initializationError.value if (error != null) { + debugLog(TAG, "configure - INIT ERROR: ${error.message}") promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}") } else { + debugLog(TAG, "configure - SUCCESS") promise.resolve(null) } } catch (e: Exception) { + debugLog(TAG, "configure - EXCEPTION: ${e.message}") promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e) } } } + /** + * Forces the Clerk SDK to re-fetch client/environment data from the API. + * + * This is needed when a new device token has been written to SharedPreferences + * but the SDK was already initialized (so Clerk.initialize() is a no-op). + * Uses reflection because ConfigurationManager.refreshClientAndEnvironment is private. + */ + /** + * Forces the Clerk SDK to re-fetch client/environment data from the API. + * + * This is needed when a new device token has been written to SharedPreferences + * but the SDK was already initialized (so Clerk.initialize() is a no-op). + * + * Uses reflection to find the ConfigurationManager instance (field name may be + * obfuscated by R8), then sets _isInitialized to false so reinitialize() proceeds. + */ + private fun forceClientRefresh() { + try { + debugLog(TAG, "forceClientRefresh - searching for ConfigurationManager field via reflection") + + // Find the ConfigurationManager field by type since the name may be obfuscated + val clerkClass = Clerk::class.java + var configManager: Any? = null + + for (field in clerkClass.declaredFields) { + field.isAccessible = true + val fieldValue = field.get(Clerk) + debugLog(TAG, "forceClientRefresh - field: ${field.name} type: ${field.type.name}") + if (fieldValue != null && fieldValue.javaClass.name.contains("ConfigurationManager")) { + configManager = fieldValue + debugLog(TAG, "forceClientRefresh - found ConfigurationManager: ${field.name} -> ${fieldValue.javaClass.name}") + break + } + } + + if (configManager == null) { + debugLog(TAG, "forceClientRefresh - ConfigurationManager not found by type, trying all fields with MutableStateFlow") + // Fallback: just try to directly set isInitialized to false via the public StateFlow + // and then call reinitialize() + debugLog(TAG, "forceClientRefresh - skipping reflection, trying alternative approach") + forceClientRefreshAlternative() + return + } + + // Find _isInitialized field (MutableStateFlow) in ConfigurationManager + var isInitFlow: MutableStateFlow? = null + for (field in configManager.javaClass.declaredFields) { + field.isAccessible = true + val fieldValue = field.get(configManager) + if (fieldValue is MutableStateFlow<*>) { + val currentVal = fieldValue.value + if (currentVal is Boolean) { + debugLog(TAG, "forceClientRefresh - found MutableStateFlow: ${field.name} = $currentVal") + if (currentVal == true) { + @Suppress("UNCHECKED_CAST") + isInitFlow = fieldValue as MutableStateFlow + break + } + } + } + } + + if (isInitFlow != null) { + debugLog(TAG, "forceClientRefresh - setting _isInitialized to false") + isInitFlow.value = false + debugLog(TAG, "forceClientRefresh - calling Clerk.reinitialize()") + val result = Clerk.reinitialize() + debugLog(TAG, "forceClientRefresh - reinitialize() returned $result, isInitialized=${Clerk.isInitialized.value}") + } else { + debugLog(TAG, "forceClientRefresh - _isInitialized flow not found, trying alternative") + forceClientRefreshAlternative() + } + } catch (e: Exception) { + debugLog(TAG, "forceClientRefresh FAILED: ${e.message}") + e.printStackTrace() + // Try alternative approach + forceClientRefreshAlternative() + } + } + + /** + * Alternative force refresh that doesn't rely on reflection for ConfigurationManager. + * Triggers the app lifecycle refresh callback by simulating a foreground return. + */ + private fun forceClientRefreshAlternative() { + try { + debugLog(TAG, "forceClientRefreshAlternative - trying lifecycle-based refresh") + + // The Clerk SDK refreshes client data when the app returns to foreground. + // We can trigger this by finding and invoking the refresh callback. + val clerkClass = Clerk::class.java + + // Look for any method or field related to lifecycle refresh + for (field in clerkClass.declaredFields) { + field.isAccessible = true + val fieldValue = field.get(Clerk) + debugLog(TAG, "forceClientRefreshAlternative - Clerk field: ${field.name} (${field.type.simpleName}) = ${fieldValue?.javaClass?.simpleName ?: "null"}") + } + + // Try to find and invoke updateClient or similar internal method + for (method in clerkClass.declaredMethods) { + debugLog(TAG, "forceClientRefreshAlternative - Clerk method: ${method.name}(${method.parameterTypes.joinToString { it.simpleName }})") + } + + debugLog(TAG, "forceClientRefreshAlternative - reflection dump complete") + } catch (e: Exception) { + debugLog(TAG, "forceClientRefreshAlternative FAILED: ${e.message}") + } + } + // MARK: - presentAuth @ReactMethod @@ -146,16 +295,22 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun presentUserProfile(options: ReadableMap, promise: Promise) { + debugLog(TAG, "presentUserProfile - START, isInitialized=${Clerk.isInitialized.value}, session=${Clerk.session?.id}, user=${Clerk.user?.id}") + val activity = getCurrentActivity() ?: run { + debugLog(TAG, "presentUserProfile - NO ACTIVITY") promise.reject("E_ACTIVITY_UNAVAILABLE", "No activity available to present Clerk UI.") return } if (!Clerk.isInitialized.value) { + debugLog(TAG, "presentUserProfile - NOT INITIALIZED") promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") return } + debugLog(TAG, "presentUserProfile - pre-launch state: session=${Clerk.session?.id}, user=${Clerk.user?.id}, publishableKey=${publishableKey?.take(20)}...") + pendingProfilePromise?.reject("E_SUPERSEDED", "Profile presentation was superseded") pendingProfilePromise = promise @@ -166,6 +321,7 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : putExtra(EXTRA_PUBLISHABLE_KEY, publishableKey) } + debugLog(TAG, "presentUserProfile - launching ClerkUserProfileActivity") activity.startActivityForResult(intent, CLERK_PROFILE_REQUEST_CODE) } @@ -173,15 +329,19 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun getSession(promise: Promise) { + debugLog(TAG, "getSession - isInitialized=${Clerk.isInitialized.value}") + if (!Clerk.isInitialized.value) { - promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") + // Return null when not initialized (matches iOS behavior) + // so callers can proceed to call configure() with a bearer token. + debugLog(TAG, "getSession - not initialized, resolving null") + promise.resolve(null) return } val session = Clerk.session val user = Clerk.user - - debugLog(TAG, "getSession - hasSession: ${session != null}, hasUser: ${user != null}") + debugLog(TAG, "getSession - session=${session?.id}, user=${user?.id}") val result = WritableNativeMap() @@ -217,10 +377,10 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : try { val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) val deviceToken = prefs.getString("DEVICE_TOKEN", null) - debugLog(TAG, "getClientToken - deviceToken: ${if (deviceToken != null) "found" else "null"}") + debugLog(TAG, "getClientToken - deviceToken: ${if (deviceToken != null) "found(${deviceToken.length} chars)" else "null"}") promise.resolve(deviceToken) } catch (e: Exception) { - debugLog(TAG, "getClientToken failed: ${e.message}") + debugLog(TAG, "getClientToken FAILED: ${e.message}") promise.resolve(null) } } @@ -229,16 +389,22 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun signOut(promise: Promise) { + debugLog(TAG, "signOut - isInitialized=${Clerk.isInitialized.value}, session=${Clerk.session?.id}") if (!Clerk.isInitialized.value) { - promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") + // Resolve gracefully when not initialized (matches iOS behavior) + debugLog(TAG, "signOut - not initialized, resolving null") + promise.resolve(null) return } coroutineScope.launch { try { + debugLog(TAG, "signOut - calling Clerk.auth.signOut()") Clerk.auth.signOut() + debugLog(TAG, "signOut - SUCCESS") promise.resolve(null) } catch (e: Exception) { + debugLog(TAG, "signOut - FAILED: ${e.message}") promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign out failed", e) } } @@ -304,12 +470,18 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } private fun handleProfileResult(resultCode: Int, data: Intent?) { - val promise = pendingProfilePromise ?: return + debugLog(TAG, "handleProfileResult - resultCode=$resultCode (OK=${Activity.RESULT_OK}, CANCELED=${Activity.RESULT_CANCELED})") + + val promise = pendingProfilePromise ?: run { + debugLog(TAG, "handleProfileResult - no pending promise!") + return + } pendingProfilePromise = null // Profile always returns current session state val session = Clerk.session val user = Clerk.user + debugLog(TAG, "handleProfileResult - session=${session?.id}, user=${user?.id}") val result = WritableNativeMap() @@ -333,7 +505,9 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : result.putMap("user", userMap) } - result.putBoolean("dismissed", resultCode == Activity.RESULT_CANCELED) + val dismissed = resultCode == Activity.RESULT_CANCELED + result.putBoolean("dismissed", dismissed) + debugLog(TAG, "handleProfileResult - resolving with dismissed=$dismissed, hasSession=${session != null}") promise.resolve(result) } diff --git a/packages/expo/src/hooks/useUserProfileModal.ts b/packages/expo/src/hooks/useUserProfileModal.ts index 1b0f2410be1..011ba7713c8 100644 --- a/packages/expo/src/hooks/useUserProfileModal.ts +++ b/packages/expo/src/hooks/useUserProfileModal.ts @@ -69,31 +69,73 @@ export function useUserProfileModal(): UseUserProfileModalReturn { presentingRef.current = true; try { + let hadNativeSessionBefore = false; + // If native doesn't have a session but JS does (e.g. user signed in via custom form), // sync the JS SDK's bearer token to native and wait for it before presenting. if (user && ClerkExpo?.getSession && ClerkExpo?.configure) { const preCheck = (await ClerkExpo.getSession()) as NativeSessionResult | null; - const hasSession = !!(preCheck?.sessionId || preCheck?.session?.id); + hadNativeSessionBefore = !!(preCheck?.sessionId || preCheck?.session?.id); + + if (__DEV__) { + console.log('[useUserProfileModal] preCheck:', JSON.stringify(preCheck)); + console.log('[useUserProfileModal] hadNativeSessionBefore:', hadNativeSessionBefore); + } - if (!hasSession) { + if (!hadNativeSessionBefore) { const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + if (__DEV__) { + console.log( + '[useUserProfileModal] bearerToken:', + bearerToken ? `present(${bearerToken.length} chars)` : 'null', + ); + } if (bearerToken) { + if (__DEV__) { + console.log('[useUserProfileModal] calling configure()...'); + } await ClerkExpo.configure(clerk.publishableKey, bearerToken); + if (__DEV__) { + console.log('[useUserProfileModal] configure() done'); + } + + // Re-check if configure produced a session + const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null; + hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id); + if (__DEV__) { + console.log('[useUserProfileModal] post-configure session:', JSON.stringify(postConfigure)); + } } } } + if (__DEV__) { + console.log('[useUserProfileModal] calling presentUserProfile()...'); + } await ClerkExpo.presentUserProfile({ dismissable: true, }); + if (__DEV__) { + console.log('[useUserProfileModal] presentUserProfile() returned'); + } - // Check if native session still exists after modal closes - // If session is null, user signed out from the native UI + // Only sign out the JS SDK if native HAD a session before the modal + // and now it's gone (user signed out from within native UI). const sessionCheck = (await ClerkExpo.getSession?.()) as NativeSessionResult | null; const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id); + if (__DEV__) { + console.log( + '[useUserProfileModal] post-modal:', + JSON.stringify(sessionCheck), + 'hadBefore:', + hadNativeSessionBefore, + ); + } - if (!hasNativeSession) { - // Clear native session explicitly (may already be cleared, but ensure it) + if (!hasNativeSession && hadNativeSessionBefore) { + if (__DEV__) { + console.log('[useUserProfileModal] native session LOST during modal, signing out'); + } try { await ClerkExpo.signOut?.(); } catch (e) { @@ -102,7 +144,6 @@ export function useUserProfileModal(): UseUserProfileModalReturn { } } - // Sign out from JS SDK to update isSignedIn state if (clerk?.signOut) { try { await clerk.signOut(); @@ -112,6 +153,8 @@ export function useUserProfileModal(): UseUserProfileModalReturn { } } } + } else if (__DEV__ && !hasNativeSession) { + console.log('[useUserProfileModal] native never had session, NOT signing out JS SDK'); } } catch (error) { if (__DEV__) { diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx index bfdd9bceb05..635c23e10f3 100644 --- a/packages/expo/src/native/UserButton.tsx +++ b/packages/expo/src/native/UserButton.tsx @@ -135,30 +135,79 @@ export function UserButton(_props: UserButtonProps) { presentingRef.current = true; try { + // Track whether native had a session before the modal, so we can distinguish + // "user signed out from within the modal" from "native never had a session". + let hadNativeSessionBefore = false; + // If native doesn't have a session but JS does (e.g. user signed in via custom form), // sync the JS SDK's bearer token to native and wait for it before presenting. if (clerkUser && ClerkExpo?.getSession && ClerkExpo?.configure) { const preCheck = (await ClerkExpo.getSession()) as NativeSessionResult | null; - const hasSession = !!(preCheck?.sessionId || preCheck?.session?.id); + hadNativeSessionBefore = !!(preCheck?.sessionId || preCheck?.session?.id); + + if (__DEV__) { + console.log('[UserButton] handlePress - preCheck:', JSON.stringify(preCheck)); + console.log('[UserButton] handlePress - hadNativeSessionBefore:', hadNativeSessionBefore); + } - if (!hasSession) { + if (!hadNativeSessionBefore) { const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + if (__DEV__) { + console.log( + '[UserButton] handlePress - bearerToken:', + bearerToken ? `present(${bearerToken.length} chars)` : 'null', + ); + } if (bearerToken) { + if (__DEV__) { + console.log('[UserButton] handlePress - calling configure()...'); + } await ClerkExpo.configure(clerk.publishableKey, bearerToken); + if (__DEV__) { + console.log('[UserButton] handlePress - configure() done'); + } + + // Re-check if configure produced a session + const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null; + hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id); + if (__DEV__) { + console.log('[UserButton] handlePress - post-configure session:', JSON.stringify(postConfigure)); + console.log('[UserButton] handlePress - hadNativeSessionBefore (updated):', hadNativeSessionBefore); + } } } } + if (__DEV__) { + console.log('[UserButton] handlePress - calling presentUserProfile()...'); + } await ClerkExpo.presentUserProfile({ dismissable: true, }); + if (__DEV__) { + console.log('[UserButton] handlePress - presentUserProfile() returned'); + } - // Check if native session still exists after modal closes - // If session is null, user signed out from the native UI + // Check if native session still exists after modal closes. + // Only sign out the JS SDK if the native SDK HAD a session before the modal + // and now it's gone (meaning the user signed out from within the native UI). + // If native never had a session (e.g. force refresh didn't work), don't sign out JS. const sessionCheck = (await ClerkExpo.getSession?.()) as NativeSessionResult | null; const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id); + if (__DEV__) { + console.log('[UserButton] handlePress - post-modal sessionCheck:', JSON.stringify(sessionCheck)); + console.log( + '[UserButton] handlePress - hasNativeSession:', + hasNativeSession, + 'hadBefore:', + hadNativeSessionBefore, + ); + } - if (!hasNativeSession) { + if (!hasNativeSession && hadNativeSessionBefore) { + if (__DEV__) { + console.log('[UserButton] handlePress - native session LOST during modal, signing out JS SDK'); + } // Clear local state immediately for instant UI feedback setNativeUser(null); @@ -181,6 +230,14 @@ export function UserButton(_props: UserButtonProps) { } } } + } else if (!hasNativeSession) { + if (__DEV__) { + console.log('[UserButton] handlePress - native never had session, NOT signing out JS SDK'); + } + } else { + if (__DEV__) { + console.log('[UserButton] handlePress - native session still exists, keeping JS SDK signed in'); + } } } catch (error) { if (__DEV__) { diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 518788eee5f..29e349ed05f 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -83,9 +83,16 @@ function NativeSessionSync({ publishableKey }: { publishableKey: string }) { try { const ClerkExpo = NativeClerkModule; if (!ClerkExpo?.configure || !ClerkExpo?.getSession) { + if (__DEV__) { + console.log('[NativeSessionSync] syncToNative - native module not available'); + } return; } + if (__DEV__) { + console.log('[NativeSessionSync] syncToNative - checking native session...'); + } + // Check if native already has a session (e.g. auth via AuthView or initial load) const nativeSession = (await ClerkExpo.getSession()) as { sessionId?: string; @@ -93,14 +100,34 @@ function NativeSessionSync({ publishableKey }: { publishableKey: string }) { } | null; const hasNativeSession = !!(nativeSession?.sessionId || nativeSession?.session?.id); + if (__DEV__) { + console.log('[NativeSessionSync] syncToNative - nativeSession:', JSON.stringify(nativeSession)); + console.log('[NativeSessionSync] syncToNative - hasNativeSession:', hasNativeSession); + } + if (hasNativeSession) { + if (__DEV__) { + console.log('[NativeSessionSync] syncToNative - native already has session, skipping'); + } return; } // Read the JS SDK's client JWT and push it to the native SDK const bearerToken = (await defaultTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + if (__DEV__) { + console.log( + '[NativeSessionSync] syncToNative - bearerToken:', + bearerToken ? `present(${bearerToken.length} chars)` : 'null', + ); + } if (bearerToken) { + if (__DEV__) { + console.log('[NativeSessionSync] syncToNative - calling configure()...'); + } await ClerkExpo.configure(publishableKey, bearerToken); + if (__DEV__) { + console.log('[NativeSessionSync] syncToNative - configure() done'); + } } } catch (error) { if (__DEV__) { From 96ca76da706ac1394a4812c4ec55ea3a1929a2be Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 10 Mar 2026 15:28:45 -0700 Subject: [PATCH 3/5] refactor(expo): remove verbose debug logging from session sync code Strip excessive console.log/debugLog statements added during Android session sync development. Keep only essential error/warn logs for production debugging. No behavioral changes. --- .../expo/modules/clerk/ClerkExpoModule.kt | 145 ++---------------- .../expo/src/hooks/useUserProfileModal.ts | 39 ----- packages/expo/src/native/UserButton.tsx | 47 ------ packages/expo/src/provider/ClerkProvider.tsx | 27 ---- 4 files changed, 17 insertions(+), 241 deletions(-) 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 b976106510e..e79cc125cb8 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 @@ -64,7 +64,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun configure(pubKey: String, bearerToken: String?, promise: Promise) { - debugLog(TAG, "configure - START pubKey=${pubKey.take(20)}... bearerToken=${if (bearerToken != null) "present(${bearerToken.length} chars)" else "null"}") coroutineScope.launch { try { publishableKey = pubKey @@ -72,37 +71,24 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : // If the JS SDK has a bearer token, write it to the native SDK's // SharedPreferences so both SDKs share the same Clerk API client. if (!bearerToken.isNullOrEmpty()) { - debugLog(TAG, "configure - writing bearer token to SharedPreferences") reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) .edit() .putString("DEVICE_TOKEN", bearerToken) .apply() - - // Verify write - val verified = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) - .getString("DEVICE_TOKEN", null) - debugLog(TAG, "configure - SharedPreferences verify: ${if (verified != null) "written(${verified.length} chars)" else "WRITE FAILED"}") } - debugLog(TAG, "configure - Clerk.isInitialized=${Clerk.isInitialized.value}") - if (Clerk.isInitialized.value) { - debugLog(TAG, "configure - already initialized, session=${Clerk.session?.id}, user=${Clerk.user?.id}") // Already initialized — force a client refresh so the SDK // picks up the new device token from SharedPreferences. - debugLog(TAG, "configure - calling forceClientRefresh()") forceClientRefresh() // Wait for session to appear with the new token (up to 5s) try { - debugLog(TAG, "configure - waiting for session (up to 5s)...") withTimeout(5_000L) { Clerk.sessionFlow.first { it != null } } - debugLog(TAG, "configure - session appeared! session=${Clerk.session?.id}, user=${Clerk.user?.id}") } catch (_: TimeoutCancellationException) { - debugLog(TAG, "configure - session did not appear after force refresh (timeout)") - debugLog(TAG, "configure - post-timeout state: session=${Clerk.session?.id}, user=${Clerk.user?.id}, client=${Clerk.client}") + debugLog(TAG, "configure - session did not appear after force refresh") } promise.resolve(null) @@ -110,7 +96,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } // First-time initialization - debugLog(TAG, "configure - first-time init, calling Clerk.initialize()") Clerk.initialize(reactApplicationContext, pubKey) // Wait for initialization to complete with timeout @@ -118,7 +103,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : withTimeout(10_000L) { Clerk.isInitialized.first { it } } - debugLog(TAG, "configure - initialized! session=${Clerk.session?.id}, user=${Clerk.user?.id}") } catch (e: TimeoutCancellationException) { val initError = Clerk.initializationError.value val message = if (initError != null) { @@ -126,7 +110,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } else { "Clerk initialization timed out after 10 seconds" } - debugLog(TAG, "configure - TIMEOUT: $message") promise.reject("E_TIMEOUT", message) return@launch } @@ -134,126 +117,62 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : // Check for initialization errors val error = Clerk.initializationError.value if (error != null) { - debugLog(TAG, "configure - INIT ERROR: ${error.message}") promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}") } else { - debugLog(TAG, "configure - SUCCESS") promise.resolve(null) } } catch (e: Exception) { - debugLog(TAG, "configure - EXCEPTION: ${e.message}") promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e) } } } - /** - * Forces the Clerk SDK to re-fetch client/environment data from the API. - * - * This is needed when a new device token has been written to SharedPreferences - * but the SDK was already initialized (so Clerk.initialize() is a no-op). - * Uses reflection because ConfigurationManager.refreshClientAndEnvironment is private. - */ /** * Forces the Clerk SDK to re-fetch client/environment data from the API. * * This is needed when a new device token has been written to SharedPreferences * but the SDK was already initialized (so Clerk.initialize() is a no-op). * - * Uses reflection to find the ConfigurationManager instance (field name may be - * obfuscated by R8), then sets _isInitialized to false so reinitialize() proceeds. + * Uses reflection to find the ConfigurationManager instance by type (field name + * may vary across SDK versions), then sets _isInitialized to false so + * reinitialize() proceeds with a fresh client/environment fetch. */ private fun forceClientRefresh() { try { - debugLog(TAG, "forceClientRefresh - searching for ConfigurationManager field via reflection") - - // Find the ConfigurationManager field by type since the name may be obfuscated + // Find the ConfigurationManager field by type since the name may differ val clerkClass = Clerk::class.java var configManager: Any? = null for (field in clerkClass.declaredFields) { field.isAccessible = true val fieldValue = field.get(Clerk) - debugLog(TAG, "forceClientRefresh - field: ${field.name} type: ${field.type.name}") if (fieldValue != null && fieldValue.javaClass.name.contains("ConfigurationManager")) { configManager = fieldValue - debugLog(TAG, "forceClientRefresh - found ConfigurationManager: ${field.name} -> ${fieldValue.javaClass.name}") break } } if (configManager == null) { - debugLog(TAG, "forceClientRefresh - ConfigurationManager not found by type, trying all fields with MutableStateFlow") - // Fallback: just try to directly set isInitialized to false via the public StateFlow - // and then call reinitialize() - debugLog(TAG, "forceClientRefresh - skipping reflection, trying alternative approach") - forceClientRefreshAlternative() + debugLog(TAG, "forceClientRefresh - ConfigurationManager not found") return } // Find _isInitialized field (MutableStateFlow) in ConfigurationManager - var isInitFlow: MutableStateFlow? = null + // and set it to false so reinitialize() will proceed for (field in configManager.javaClass.declaredFields) { field.isAccessible = true val fieldValue = field.get(configManager) - if (fieldValue is MutableStateFlow<*>) { - val currentVal = fieldValue.value - if (currentVal is Boolean) { - debugLog(TAG, "forceClientRefresh - found MutableStateFlow: ${field.name} = $currentVal") - if (currentVal == true) { - @Suppress("UNCHECKED_CAST") - isInitFlow = fieldValue as MutableStateFlow - break - } - } + if (fieldValue is MutableStateFlow<*> && fieldValue.value is Boolean && fieldValue.value == true) { + @Suppress("UNCHECKED_CAST") + (fieldValue as MutableStateFlow).value = false + Clerk.reinitialize() + return } } - if (isInitFlow != null) { - debugLog(TAG, "forceClientRefresh - setting _isInitialized to false") - isInitFlow.value = false - debugLog(TAG, "forceClientRefresh - calling Clerk.reinitialize()") - val result = Clerk.reinitialize() - debugLog(TAG, "forceClientRefresh - reinitialize() returned $result, isInitialized=${Clerk.isInitialized.value}") - } else { - debugLog(TAG, "forceClientRefresh - _isInitialized flow not found, trying alternative") - forceClientRefreshAlternative() - } + debugLog(TAG, "forceClientRefresh - _isInitialized flow not found") } catch (e: Exception) { - debugLog(TAG, "forceClientRefresh FAILED: ${e.message}") - e.printStackTrace() - // Try alternative approach - forceClientRefreshAlternative() - } - } - - /** - * Alternative force refresh that doesn't rely on reflection for ConfigurationManager. - * Triggers the app lifecycle refresh callback by simulating a foreground return. - */ - private fun forceClientRefreshAlternative() { - try { - debugLog(TAG, "forceClientRefreshAlternative - trying lifecycle-based refresh") - - // The Clerk SDK refreshes client data when the app returns to foreground. - // We can trigger this by finding and invoking the refresh callback. - val clerkClass = Clerk::class.java - - // Look for any method or field related to lifecycle refresh - for (field in clerkClass.declaredFields) { - field.isAccessible = true - val fieldValue = field.get(Clerk) - debugLog(TAG, "forceClientRefreshAlternative - Clerk field: ${field.name} (${field.type.simpleName}) = ${fieldValue?.javaClass?.simpleName ?: "null"}") - } - - // Try to find and invoke updateClient or similar internal method - for (method in clerkClass.declaredMethods) { - debugLog(TAG, "forceClientRefreshAlternative - Clerk method: ${method.name}(${method.parameterTypes.joinToString { it.simpleName }})") - } - - debugLog(TAG, "forceClientRefreshAlternative - reflection dump complete") - } catch (e: Exception) { - debugLog(TAG, "forceClientRefreshAlternative FAILED: ${e.message}") + debugLog(TAG, "forceClientRefresh failed: ${e.message}") } } @@ -295,22 +214,16 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun presentUserProfile(options: ReadableMap, promise: Promise) { - debugLog(TAG, "presentUserProfile - START, isInitialized=${Clerk.isInitialized.value}, session=${Clerk.session?.id}, user=${Clerk.user?.id}") - val activity = getCurrentActivity() ?: run { - debugLog(TAG, "presentUserProfile - NO ACTIVITY") promise.reject("E_ACTIVITY_UNAVAILABLE", "No activity available to present Clerk UI.") return } if (!Clerk.isInitialized.value) { - debugLog(TAG, "presentUserProfile - NOT INITIALIZED") promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") return } - debugLog(TAG, "presentUserProfile - pre-launch state: session=${Clerk.session?.id}, user=${Clerk.user?.id}, publishableKey=${publishableKey?.take(20)}...") - pendingProfilePromise?.reject("E_SUPERSEDED", "Profile presentation was superseded") pendingProfilePromise = promise @@ -321,7 +234,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : putExtra(EXTRA_PUBLISHABLE_KEY, publishableKey) } - debugLog(TAG, "presentUserProfile - launching ClerkUserProfileActivity") activity.startActivityForResult(intent, CLERK_PROFILE_REQUEST_CODE) } @@ -329,19 +241,15 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun getSession(promise: Promise) { - debugLog(TAG, "getSession - isInitialized=${Clerk.isInitialized.value}") - if (!Clerk.isInitialized.value) { // Return null when not initialized (matches iOS behavior) // so callers can proceed to call configure() with a bearer token. - debugLog(TAG, "getSession - not initialized, resolving null") promise.resolve(null) return } val session = Clerk.session val user = Clerk.user - debugLog(TAG, "getSession - session=${session?.id}, user=${user?.id}") val result = WritableNativeMap() @@ -377,10 +285,9 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : try { val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) val deviceToken = prefs.getString("DEVICE_TOKEN", null) - debugLog(TAG, "getClientToken - deviceToken: ${if (deviceToken != null) "found(${deviceToken.length} chars)" else "null"}") promise.resolve(deviceToken) } catch (e: Exception) { - debugLog(TAG, "getClientToken FAILED: ${e.message}") + debugLog(TAG, "getClientToken failed: ${e.message}") promise.resolve(null) } } @@ -389,22 +296,17 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun signOut(promise: Promise) { - debugLog(TAG, "signOut - isInitialized=${Clerk.isInitialized.value}, session=${Clerk.session?.id}") if (!Clerk.isInitialized.value) { // Resolve gracefully when not initialized (matches iOS behavior) - debugLog(TAG, "signOut - not initialized, resolving null") promise.resolve(null) return } coroutineScope.launch { try { - debugLog(TAG, "signOut - calling Clerk.auth.signOut()") Clerk.auth.signOut() - debugLog(TAG, "signOut - SUCCESS") promise.resolve(null) } catch (e: Exception) { - debugLog(TAG, "signOut - FAILED: ${e.message}") promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign out failed", e) } } @@ -424,8 +326,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } private fun handleAuthResult(resultCode: Int, data: Intent?) { - debugLog(TAG, "handleAuthResult - resultCode: $resultCode") - val promise = pendingAuthPromise ?: return pendingAuthPromise = null @@ -433,8 +333,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : val session = Clerk.session val user = Clerk.user - debugLog(TAG, "handleAuthResult - hasSession: ${session != null}, hasUser: ${user != null}") - val result = WritableNativeMap() // Top-level sessionId for JS SDK compatibility (matches iOS response format) @@ -462,7 +360,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : promise.resolve(result) } else { - debugLog(TAG, "handleAuthResult - user cancelled") val result = WritableNativeMap() result.putBoolean("cancelled", true) promise.resolve(result) @@ -470,18 +367,12 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } private fun handleProfileResult(resultCode: Int, data: Intent?) { - debugLog(TAG, "handleProfileResult - resultCode=$resultCode (OK=${Activity.RESULT_OK}, CANCELED=${Activity.RESULT_CANCELED})") - - val promise = pendingProfilePromise ?: run { - debugLog(TAG, "handleProfileResult - no pending promise!") - return - } + val promise = pendingProfilePromise ?: return pendingProfilePromise = null // Profile always returns current session state val session = Clerk.session val user = Clerk.user - debugLog(TAG, "handleProfileResult - session=${session?.id}, user=${user?.id}") val result = WritableNativeMap() @@ -505,9 +396,7 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : result.putMap("user", userMap) } - val dismissed = resultCode == Activity.RESULT_CANCELED - result.putBoolean("dismissed", dismissed) - debugLog(TAG, "handleProfileResult - resolving with dismissed=$dismissed, hasSession=${session != null}") + result.putBoolean("dismissed", resultCode == Activity.RESULT_CANCELED) promise.resolve(result) } diff --git a/packages/expo/src/hooks/useUserProfileModal.ts b/packages/expo/src/hooks/useUserProfileModal.ts index 011ba7713c8..da7c6f4d081 100644 --- a/packages/expo/src/hooks/useUserProfileModal.ts +++ b/packages/expo/src/hooks/useUserProfileModal.ts @@ -77,65 +77,28 @@ export function useUserProfileModal(): UseUserProfileModalReturn { const preCheck = (await ClerkExpo.getSession()) as NativeSessionResult | null; hadNativeSessionBefore = !!(preCheck?.sessionId || preCheck?.session?.id); - if (__DEV__) { - console.log('[useUserProfileModal] preCheck:', JSON.stringify(preCheck)); - console.log('[useUserProfileModal] hadNativeSessionBefore:', hadNativeSessionBefore); - } - if (!hadNativeSessionBefore) { const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; - if (__DEV__) { - console.log( - '[useUserProfileModal] bearerToken:', - bearerToken ? `present(${bearerToken.length} chars)` : 'null', - ); - } if (bearerToken) { - if (__DEV__) { - console.log('[useUserProfileModal] calling configure()...'); - } await ClerkExpo.configure(clerk.publishableKey, bearerToken); - if (__DEV__) { - console.log('[useUserProfileModal] configure() done'); - } // Re-check if configure produced a session const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null; hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id); - if (__DEV__) { - console.log('[useUserProfileModal] post-configure session:', JSON.stringify(postConfigure)); - } } } } - if (__DEV__) { - console.log('[useUserProfileModal] calling presentUserProfile()...'); - } await ClerkExpo.presentUserProfile({ dismissable: true, }); - if (__DEV__) { - console.log('[useUserProfileModal] presentUserProfile() returned'); - } // Only sign out the JS SDK if native HAD a session before the modal // and now it's gone (user signed out from within native UI). const sessionCheck = (await ClerkExpo.getSession?.()) as NativeSessionResult | null; const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id); - if (__DEV__) { - console.log( - '[useUserProfileModal] post-modal:', - JSON.stringify(sessionCheck), - 'hadBefore:', - hadNativeSessionBefore, - ); - } if (!hasNativeSession && hadNativeSessionBefore) { - if (__DEV__) { - console.log('[useUserProfileModal] native session LOST during modal, signing out'); - } try { await ClerkExpo.signOut?.(); } catch (e) { @@ -153,8 +116,6 @@ export function useUserProfileModal(): UseUserProfileModalReturn { } } } - } else if (__DEV__ && !hasNativeSession) { - console.log('[useUserProfileModal] native never had session, NOT signing out JS SDK'); } } catch (error) { if (__DEV__) { diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx index 635c23e10f3..4e0795970ff 100644 --- a/packages/expo/src/native/UserButton.tsx +++ b/packages/expo/src/native/UserButton.tsx @@ -145,48 +145,21 @@ export function UserButton(_props: UserButtonProps) { const preCheck = (await ClerkExpo.getSession()) as NativeSessionResult | null; hadNativeSessionBefore = !!(preCheck?.sessionId || preCheck?.session?.id); - if (__DEV__) { - console.log('[UserButton] handlePress - preCheck:', JSON.stringify(preCheck)); - console.log('[UserButton] handlePress - hadNativeSessionBefore:', hadNativeSessionBefore); - } - if (!hadNativeSessionBefore) { const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; - if (__DEV__) { - console.log( - '[UserButton] handlePress - bearerToken:', - bearerToken ? `present(${bearerToken.length} chars)` : 'null', - ); - } if (bearerToken) { - if (__DEV__) { - console.log('[UserButton] handlePress - calling configure()...'); - } await ClerkExpo.configure(clerk.publishableKey, bearerToken); - if (__DEV__) { - console.log('[UserButton] handlePress - configure() done'); - } // Re-check if configure produced a session const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null; hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id); - if (__DEV__) { - console.log('[UserButton] handlePress - post-configure session:', JSON.stringify(postConfigure)); - console.log('[UserButton] handlePress - hadNativeSessionBefore (updated):', hadNativeSessionBefore); - } } } } - if (__DEV__) { - console.log('[UserButton] handlePress - calling presentUserProfile()...'); - } await ClerkExpo.presentUserProfile({ dismissable: true, }); - if (__DEV__) { - console.log('[UserButton] handlePress - presentUserProfile() returned'); - } // Check if native session still exists after modal closes. // Only sign out the JS SDK if the native SDK HAD a session before the modal @@ -194,20 +167,8 @@ export function UserButton(_props: UserButtonProps) { // If native never had a session (e.g. force refresh didn't work), don't sign out JS. const sessionCheck = (await ClerkExpo.getSession?.()) as NativeSessionResult | null; const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id); - if (__DEV__) { - console.log('[UserButton] handlePress - post-modal sessionCheck:', JSON.stringify(sessionCheck)); - console.log( - '[UserButton] handlePress - hasNativeSession:', - hasNativeSession, - 'hadBefore:', - hadNativeSessionBefore, - ); - } if (!hasNativeSession && hadNativeSessionBefore) { - if (__DEV__) { - console.log('[UserButton] handlePress - native session LOST during modal, signing out JS SDK'); - } // Clear local state immediately for instant UI feedback setNativeUser(null); @@ -230,14 +191,6 @@ export function UserButton(_props: UserButtonProps) { } } } - } else if (!hasNativeSession) { - if (__DEV__) { - console.log('[UserButton] handlePress - native never had session, NOT signing out JS SDK'); - } - } else { - if (__DEV__) { - console.log('[UserButton] handlePress - native session still exists, keeping JS SDK signed in'); - } } } catch (error) { if (__DEV__) { diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 29e349ed05f..518788eee5f 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -83,16 +83,9 @@ function NativeSessionSync({ publishableKey }: { publishableKey: string }) { try { const ClerkExpo = NativeClerkModule; if (!ClerkExpo?.configure || !ClerkExpo?.getSession) { - if (__DEV__) { - console.log('[NativeSessionSync] syncToNative - native module not available'); - } return; } - if (__DEV__) { - console.log('[NativeSessionSync] syncToNative - checking native session...'); - } - // Check if native already has a session (e.g. auth via AuthView or initial load) const nativeSession = (await ClerkExpo.getSession()) as { sessionId?: string; @@ -100,34 +93,14 @@ function NativeSessionSync({ publishableKey }: { publishableKey: string }) { } | null; const hasNativeSession = !!(nativeSession?.sessionId || nativeSession?.session?.id); - if (__DEV__) { - console.log('[NativeSessionSync] syncToNative - nativeSession:', JSON.stringify(nativeSession)); - console.log('[NativeSessionSync] syncToNative - hasNativeSession:', hasNativeSession); - } - if (hasNativeSession) { - if (__DEV__) { - console.log('[NativeSessionSync] syncToNative - native already has session, skipping'); - } return; } // Read the JS SDK's client JWT and push it to the native SDK const bearerToken = (await defaultTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; - if (__DEV__) { - console.log( - '[NativeSessionSync] syncToNative - bearerToken:', - bearerToken ? `present(${bearerToken.length} chars)` : 'null', - ); - } if (bearerToken) { - if (__DEV__) { - console.log('[NativeSessionSync] syncToNative - calling configure()...'); - } await ClerkExpo.configure(publishableKey, bearerToken); - if (__DEV__) { - console.log('[NativeSessionSync] syncToNative - configure() done'); - } } } catch (error) { if (__DEV__) { From 3b1d3ee186414bc5be9b9d8d8c5aaea29d7ef10e Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 10 Mar 2026 16:39:47 -0700 Subject: [PATCH 4/5] fix(expo): use user-provided tokenCache for native session sync NativeSessionSync and configureNativeClerk were reading from the default SecureStore-based tokenCache rather than honoring the custom tokenCache prop passed to ClerkProvider. This caused native configure to be skipped when a custom cache was used, since the bearer token would be stored in the custom cache but read from the default one. Pass the tokenCache prop through to NativeSessionSync and use the effective cache (user-provided or default) in both sync paths. --- packages/expo/src/provider/ClerkProvider.tsx | 27 +++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 518788eee5f..35ff236e7d3 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -63,9 +63,17 @@ const SDK_METADATA = { * * Must be rendered inside `ClerkReactProvider` so `useAuth()` has access to context. */ -function NativeSessionSync({ publishableKey }: { publishableKey: string }) { +function NativeSessionSync({ + publishableKey, + tokenCache, +}: { + publishableKey: string; + tokenCache: TokenCache | undefined; +}) { const { isSignedIn } = useAuth(); const hasSyncedRef = useRef(false); + // Use the provided tokenCache, falling back to the default SecureStore cache + const effectiveTokenCache = tokenCache ?? defaultTokenCache; useEffect(() => { if (!isSignedIn) { @@ -98,7 +106,7 @@ function NativeSessionSync({ publishableKey }: { publishableKey: string }) { } // Read the JS SDK's client JWT and push it to the native SDK - const bearerToken = (await defaultTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + const bearerToken = (await effectiveTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; if (bearerToken) { await ClerkExpo.configure(publishableKey, bearerToken); } @@ -110,7 +118,7 @@ function NativeSessionSync({ publishableKey }: { publishableKey: string }) { }; void syncToNative(); - }, [isSignedIn, publishableKey]); + }, [isSignedIn, publishableKey, effectiveTokenCache]); return null; } @@ -163,10 +171,12 @@ export function ClerkProvider(props: ClerkProviderProps(props: ClerkProviderProps - {isNative() && } + {isNative() && ( + + )} {children} ); From 78b41ddd4611c7bded0cd530f59fa2f5a1fa97de Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 10 Mar 2026 18:00:27 -0700 Subject: [PATCH 5/5] fix(expo): defer NativeSessionSync flag until sync actually succeeds Move hasSyncedRef assignment into syncToNative so the guard flag is only set after confirming native already has a session or after successfully pushing the bearer token. Previously the flag was set synchronously before the async work, preventing retries on failure. --- packages/expo/src/provider/ClerkProvider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 35ff236e7d3..b842485b1da 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -85,8 +85,6 @@ function NativeSessionSync({ return; } - hasSyncedRef.current = true; - const syncToNative = async () => { try { const ClerkExpo = NativeClerkModule; @@ -102,6 +100,7 @@ function NativeSessionSync({ const hasNativeSession = !!(nativeSession?.sessionId || nativeSession?.session?.id); if (hasNativeSession) { + hasSyncedRef.current = true; return; } @@ -109,6 +108,7 @@ function NativeSessionSync({ const bearerToken = (await effectiveTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; if (bearerToken) { await ClerkExpo.configure(publishableKey, bearerToken); + hasSyncedRef.current = true; } } catch (error) { if (__DEV__) {