From d1902b91207f79b3af6f598aaf11490f1ceb2149 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Mon, 16 Mar 2026 09:42:10 -0700 Subject: [PATCH 1/8] feat(expo): re-introduce two-way JS/native session sync Re-applies the changes from #8032 which was reverted in #8065. This PR exists for visibility and review before re-merging. Original changes: - Two-way JS/native token sync for expo native components - Native session cleared on sign-out - Improved initialization error handling with timeout/failure messages - Additional debug logging in development --- .changeset/native-session-sync-v2.md | 5 + cross-platform-components-response.md | 42 +++ packages/expo/android/build.gradle | 2 +- .../expo/modules/clerk/ClerkExpoModule.kt | 92 +++-- packages/expo/ios/ClerkExpoModule.swift | 53 +-- packages/expo/ios/ClerkViewFactory.swift | 330 +++++++++++------- .../expo/ios/templates/ClerkViewFactory.swift | 158 +++++++-- .../expo/src/hooks/useUserProfileModal.ts | 37 +- packages/expo/src/native/UserButton.tsx | 47 ++- packages/expo/src/provider/ClerkProvider.tsx | 110 +++++- 10 files changed, 629 insertions(+), 247 deletions(-) create mode 100644 .changeset/native-session-sync-v2.md create mode 100644 cross-platform-components-response.md diff --git a/.changeset/native-session-sync-v2.md b/.changeset/native-session-sync-v2.md new file mode 100644 index 00000000000..1c194dfe02d --- /dev/null +++ b/.changeset/native-session-sync-v2.md @@ -0,0 +1,5 @@ +--- +"@clerk/expo": patch +--- + +Re-introduce two-way JS/native session sync for expo native components diff --git a/cross-platform-components-response.md b/cross-platform-components-response.md new file mode 100644 index 00000000000..a228b320bcc --- /dev/null +++ b/cross-platform-components-response.md @@ -0,0 +1,42 @@ +Hey Bryce, yeah I've been thinking about this. Here's where my head is at: + +**Short answer:** Yes, we can support cross-platform components with a unified API, and our current architecture already supports it — no native rewrites needed. But I think Replit's immediate blockers are actually simpler than that. + +**What Replit probably needs right now** + +Looking at Yu's thread, their setup is: programmatically generated apps via Platform API, their own sign-in UI, web preview in Replit + deploy to native, one codebase/one set of keys. I don't think they need our prebuilt native components — they need the headless hooks (`useSignIn`, `useSignUp`, `useSSO`) to work reliably across web and native, which they mostly do today. + +Their two actual blockers are bugs on our side: + +1. **`@clerk/expo` crashes in web view** — Yu showed this in the video. The old `@clerk/clerk-expo` works fine but v3 tries to initialize TurboModules that don't exist in web mode. This is a targeted fix in our ClerkProvider initialization path. + +2. **`proxyUrl` is silently ignored on native** — I dug into this and found that `proxyUrl` is passed to `ClerkReactProvider` (web side) but never forwarded to the native Clerk instance in `getClerkInstance()`. The `BuildClerkOptions` type doesn't even have a `proxyUrl` field. This is why Yu's Google SSO works on dev (no proxy) but breaks on prod (with proxy). Also a targeted fix. + +We should prioritize fixing those two — they're both small/scoped and unblock Replit immediately. + +**On cross-platform components as a broader initiative** + +The architecture already has the building blocks: + +- `tsconfig` has `moduleSuffixes: [".web", ".ios", ".android", ".native", ""]` so the bundler auto-selects platform files +- Native components (`AuthView`, `UserButton`, `UserProfileView`) already have fallback paths when native modules aren't available +- The `WrapComponent` pattern in `uiComponents.tsx` already does platform switching (currently throws on native instead of rendering the native component) + +A unified component would just be thin glue files: + +``` +src/components/SignIn.native.tsx → renders +src/components/SignIn.web.tsx → renders from @clerk/react +``` + +I'd call it **medium effort** — the implementation per component is ~50 lines of glue code, but the real cost is API design (deciding what's exposed, what's platform-specific, what gets silently ignored) and testing across iOS/Android/web. + +One caveat: native components require the Expo config plugin + prebuild, so they won't work in Expo Go. Worth confirming with Yu whether they're using Expo Go or dev builds — and more broadly, whether they even want our prebuilt components or just the headless hooks with their own UI. + +**Suggested next steps:** + +1. Fix the web mode crash and `proxyUrl` bug — unblocks Replit now +2. Ask Yu: "Are you building custom sign-in UI or do you need our prebuilt components?" — clarifies if the component work matters for them +3. If there's appetite, scope out the unified component API as a follow-up initiative for the broader Expo community + +Happy to dig deeper on any of this. diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle index a57de111813..5c0a26072f3 100644 --- a/packages/expo/android/build.gradle +++ b/packages/expo/android/build.gradle @@ -18,7 +18,7 @@ ext { credentialsVersion = "1.3.0" googleIdVersion = "1.1.1" kotlinxCoroutinesVersion = "1.7.3" - clerkAndroidApiVersion = "1.0.6" + clerkAndroidApiVersion = "1.0.9" clerkAndroidUiVersion = "1.0.9" composeVersion = "1.7.0" activityComposeVersion = "1.9.0" 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..efd132d7633 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.util.Log import com.clerk.api.Clerk +import com.clerk.api.network.serialization.ClerkResult import com.facebook.react.bridge.ActivityEventListener import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext @@ -67,41 +68,63 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : try { publishableKey = pubKey - // 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()) { - reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) - .edit() - .putString("DEVICE_TOKEN", bearerToken) - .apply() - debugLog(TAG, "configure - wrote JS bearer token to native SharedPreferences") - } - - Clerk.initialize(reactApplicationContext, pubKey) + if (!Clerk.isInitialized.value) { + // First-time initialization — write the bearer token to SharedPreferences + // before initializing so the SDK boots with the correct client. + if (!bearerToken.isNullOrEmpty()) { + reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) + .edit() + .putString("DEVICE_TOKEN", bearerToken) + .apply() + } - // Wait for initialization to complete with timeout - try { - withTimeout(10_000L) { - Clerk.isInitialized.first { it } + Clerk.initialize(reactApplicationContext, pubKey) + + // Wait for initialization to complete with timeout + try { + withTimeout(10_000L) { + Clerk.isInitialized.first { it } + } + } catch (e: TimeoutCancellationException) { + val initError = Clerk.initializationError.value + val message = if (initError != null) { + "Clerk initialization timed out: ${initError.message}" + } else { + "Clerk initialization timed out after 10 seconds" + } + promise.reject("E_TIMEOUT", message) + return@launch } - } catch (e: TimeoutCancellationException) { - val initError = Clerk.initializationError.value - val message = if (initError != null) { - "Clerk initialization timed out: ${initError.message}" + + // Check for initialization errors + val error = Clerk.initializationError.value + if (error != null) { + promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}") } else { - "Clerk initialization timed out after 10 seconds" + promise.resolve(null) } - promise.reject("E_TIMEOUT", message) return@launch } - // Check for initialization errors - val error = Clerk.initializationError.value - if (error != null) { - promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}") - } else { - promise.resolve(null) + // Already initialized — use the public SDK API to update + // the device token and trigger a client/environment refresh. + if (!bearerToken.isNullOrEmpty()) { + val result = Clerk.updateDeviceToken(bearerToken) + if (result is ClerkResult.Failure) { + debugLog(TAG, "configure - updateDeviceToken failed: ${result.error}") + } + + // Wait for session to appear with the new token (up to 5s) + try { + withTimeout(5_000L) { + Clerk.sessionFlow.first { it != null } + } + } catch (_: TimeoutCancellationException) { + debugLog(TAG, "configure - session did not appear after token update") + } } + + promise.resolve(null) } catch (e: Exception) { promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e) } @@ -174,15 +197,15 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun getSession(promise: Promise) { 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. + promise.resolve(null) return } val session = Clerk.session val user = Clerk.user - debugLog(TAG, "getSession - hasSession: ${session != null}, hasUser: ${user != null}") - val result = WritableNativeMap() session?.let { @@ -217,7 +240,6 @@ 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"}") promise.resolve(deviceToken) } catch (e: Exception) { debugLog(TAG, "getClientToken failed: ${e.message}") @@ -230,7 +252,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun signOut(promise: Promise) { 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) + promise.resolve(null) return } @@ -258,8 +281,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } private fun handleAuthResult(resultCode: Int, data: Intent?) { - debugLog(TAG, "handleAuthResult - resultCode: $resultCode") - val promise = pendingAuthPromise ?: return pendingAuthPromise = null @@ -267,8 +288,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) @@ -296,7 +315,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : promise.resolve(result) } else { - debugLog(TAG, "handleAuthResult - user cancelled") val result = WritableNativeMap() result.putBoolean("cancelled", true) promise.resolve(result) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index eabfb44d685..f1fa57788a5 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -22,6 +22,7 @@ public protocol ClerkViewFactoryProtocol { // SDK operations func configure(publishableKey: String, bearerToken: String?) async throws func getSession() async -> [String: Any]? + func getClientToken() -> String? func signOut() async throws } @@ -31,9 +32,11 @@ public protocol ClerkViewFactoryProtocol { class ClerkExpoModule: RCTEventEmitter { private static var _hasListeners = false + private static weak var sharedInstance: ClerkExpoModule? override init() { super.init() + ClerkExpoModule.sharedInstance = self } @objc override static func requiresMainQueueSetup() -> Bool { @@ -52,6 +55,17 @@ class ClerkExpoModule: RCTEventEmitter { ClerkExpoModule._hasListeners = false } + /// Emits an onAuthStateChange event to JS from anywhere in the native layer. + /// Used by inline views (AuthView, UserProfileView) to notify ClerkProvider + /// of auth state changes in addition to the view-level onAuthEvent callback. + static func emitAuthStateChange(type: String, sessionId: String?) { + guard _hasListeners, let instance = sharedInstance else { return } + instance.sendEvent(withName: "onAuthStateChange", body: [ + "type": type, + "sessionId": sessionId as Any, + ]) + } + /// Returns the topmost presented view controller, avoiding deprecated `keyWindow`. private static func topViewController() -> UIViewController? { guard let scene = UIApplication.shared.connectedScenes @@ -174,31 +188,12 @@ class ClerkExpoModule: RCTEventEmitter { @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 { + guard let factory = clerkViewFactory else { resolve(nil) + return } + + resolve(factory.getClientToken()) } // MARK: - signOut @@ -277,6 +272,12 @@ public class ClerkAuthNativeView: UIView { let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data() let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" self?.onAuthEvent?(["type": eventName, "data": jsonString]) + + // Also emit module-level event so ClerkProvider's useNativeAuthEvents picks it up + if eventName == "signInCompleted" || eventName == "signUpCompleted" { + let sessionId = data["sessionId"] as? String + ClerkExpoModule.emitAuthStateChange(type: "signedIn", sessionId: sessionId) + } } ) else { return } @@ -359,6 +360,12 @@ public class ClerkUserProfileNativeView: UIView { let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data() let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" self?.onProfileEvent?(["type": eventName, "data": jsonString]) + + // Also emit module-level event for sign-out detection + if eventName == "signedOut" { + let sessionId = data["sessionId"] as? String + ClerkExpoModule.emitAuthStateChange(type: "signedOut", sessionId: sessionId) + } } ) else { return } diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index d4a80aa6bc6..c2c5327b7f0 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -11,11 +11,19 @@ import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol // MARK: - View Factory Implementation -public class ClerkViewFactory: ClerkViewFactoryProtocol { +public final class ClerkViewFactory: ClerkViewFactoryProtocol { public static let shared = ClerkViewFactory() private static let clerkLoadMaxAttempts = 30 private static let clerkLoadIntervalNs: UInt64 = 100_000_000 + private static var clerkConfigured = false + + private enum KeychainKey { + static let jsClientJWT = "__clerk_client_jwt" + static let nativeDeviceToken = "clerkDeviceToken" + static let cachedClient = "cachedClient" + static let cachedEnvironment = "cachedEnvironment" + } private init() {} @@ -28,6 +36,11 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { return Bundle.main.bundleIdentifier } + private static var keychain: ExpoKeychain? { + guard let service = keychainService, !service.isEmpty else { return nil } + return ExpoKeychain(service: service) + } + // Register this factory with the ClerkExpo module public static func register() { clerkViewFactory = shared @@ -35,24 +48,64 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { @MainActor public func configure(publishableKey: String, bearerToken: String? = nil) async throws { + 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 a no-op on subsequent calls, so we use refreshClient(). + if Self.shouldRefreshConfiguredClient(for: bearerToken) { + _ = try? await Clerk.shared.refreshClient() + return + } + + Self.clerkConfigured = true + Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions()) + + await Self.waitForLoadedSession() + } + + 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 { - Self.writeNativeDeviceTokenIfNeeded(token) - } else { - Self.syncJSTokenToNativeKeychainIfNeeded() + 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 } - Clerk.configure(publishableKey: publishableKey) + syncJSTokenToNativeKeychainIfNeeded() + } + + private static func shouldRefreshConfiguredClient(for bearerToken: String?) -> Bool { + clerkConfigured && !(bearerToken?.isEmpty ?? true) + } + + private static func makeClerkOptions() -> Clerk.Options { + guard let service = keychainService else { + return .init() + } + return .init(keychainConfig: .init(service: service)) + } + @MainActor + private static func waitForLoadedSession() async { // Wait for Clerk to finish loading (cached data + API refresh). // The static configure() fires off async refreshes; poll until loaded. - for _ in 0.. String? { + keychain?.string(forKey: KeychainKey.nativeDeviceToken) + } - let nativeTokenKey = "clerkDeviceToken" + /// 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) + } - // Check if native SDK already has a device token — don't overwrite - let checkQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: nativeTokenKey, - kSecReturnData as String: false, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess { - return - } + /// 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) + } - // 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 getClientToken() -> String? { + Self.readNativeDeviceToken() } public func createAuthViewController( @@ -142,18 +149,8 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void ) -> UIViewController? { - let authMode: AuthView.Mode - switch mode { - case "signIn": - authMode = .signIn - case "signUp": - authMode = .signUp - default: - authMode = .signInOrUp - } - let wrapper = ClerkAuthWrapperViewController( - mode: authMode, + mode: Self.authMode(from: mode), dismissable: dismissable, completion: completion ) @@ -178,39 +175,25 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void ) -> UIViewController? { - let authMode: AuthView.Mode - switch mode { - case "signIn": - authMode = .signIn - case "signUp": - authMode = .signUp - default: - authMode = .signInOrUp - } - - let hostingController = UIHostingController( + makeHostingController( rootView: ClerkInlineAuthWrapperView( - mode: authMode, + mode: Self.authMode(from: mode), dismissable: dismissable, onEvent: onEvent ) ) - hostingController.view.backgroundColor = .clear - return hostingController } public func createUserProfileView( dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void ) -> UIViewController? { - let hostingController = UIHostingController( + makeHostingController( rootView: ClerkInlineProfileWrapperView( dismissable: dismissable, onEvent: onEvent ) ) - hostingController.view.backgroundColor = .clear - return hostingController } @MainActor @@ -218,41 +201,124 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { guard let session = Clerk.shared.session else { return nil } + return Self.sessionPayload(from: session, user: session.user ?? Clerk.shared.user) + } + + @MainActor + public func signOut() async throws { + guard let sessionId = Clerk.shared.session?.id else { return } + try await Clerk.shared.auth.signOut(sessionId: sessionId) + + // Clear all keychain data (device token, cached client/environment, etc.) + // so the native SDK doesn't boot with a stale token on next launch. + Clerk.clearAllKeychainItems() + Self.clerkConfigured = false + } + + private static func authMode(from mode: String) -> AuthView.Mode { + switch mode { + case "signIn": + .signIn + case "signUp": + .signUp + default: + .signInOrUp + } + } + + private func makeHostingController(rootView: Content) -> UIViewController { + let hostingController = UIHostingController(rootView: rootView) + hostingController.view.backgroundColor = .clear + return hostingController + } - var result: [String: Any] = [ + private static func sessionPayload(from session: Session, user: User?) -> [String: Any] { + var payload: [String: Any] = [ "sessionId": session.id, "status": String(describing: session.status) ] - // Include user details if available - let user = session.user ?? Clerk.shared.user + if let user { + payload["user"] = userPayload(from: user) + } + + return payload + } + + private static func userPayload(from user: User) -> [String: Any] { + var payload: [String: Any] = [ + "id": user.id, + "imageUrl": user.imageUrl + ] + + if let firstName = user.firstName { + payload["firstName"] = firstName + } + if let lastName = user.lastName { + payload["lastName"] = lastName + } + if let primaryEmail = user.emailAddresses.first(where: { $0.id == user.primaryEmailAddressId }) { + payload["primaryEmailAddress"] = primaryEmail.emailAddress + } else if let firstEmail = user.emailAddresses.first { + payload["primaryEmailAddress"] = firstEmail.emailAddress + } + + return payload + } +} + +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) + } - if let user = user { - var userDict: [String: Any] = [ - "id": user.id, - "imageUrl": user.imageUrl + 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, ] - if let firstName = user.firstName { - userDict["firstName"] = firstName - } - if let lastName = user.lastName { - userDict["lastName"] = lastName - } - if let primaryEmail = user.emailAddresses.first(where: { $0.id == user.primaryEmailAddressId }) { - userDict["primaryEmailAddress"] = primaryEmail.emailAddress - } else if let firstEmail = user.emailAddresses.first { - userDict["primaryEmailAddress"] = firstEmail.emailAddress - } - result["user"] = userDict + SecItemUpdate(baseQuery(for: key) as CFDictionary, attributes as CFDictionary) } + } - return result + func delete(_ key: String) { + SecItemDelete(baseQuery(for: key) as CFDictionary) } - @MainActor - public func signOut() async throws { - guard let sessionId = Clerk.shared.session?.id else { return } - try await Clerk.shared.auth.signOut(sessionId: sessionId) + 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, + ] } } @@ -415,19 +481,24 @@ struct ClerkInlineAuthWrapperView: View { var body: some View { AuthView(mode: mode, isDismissable: dismissable) .environment(Clerk.shared) + // Primary detection: observe Clerk.shared.session directly (matches Android's sessionFlow approach). + // This is more reliable than auth.events which may not emit for inline AuthView sign-ins. + .onChange(of: Clerk.shared.session?.id) { _, newSessionId in + guard let sessionId = newSessionId else { return } + sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") + } + // Fallback: also listen to auth.events for signUp events and edge cases .task { for await event in Clerk.shared.auth.events { guard !eventSent else { continue } switch event { case .signInCompleted(let signIn): - // Use createdSessionId if available, fall back to current session let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } case .signUpCompleted(let signUp): let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signUpCompleted") } case .sessionChanged(_, let newSession): - // Catches auth completion even when signIn/signUp events lack a sessionId if let sessionId = newSession?.id { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } default: break @@ -458,4 +529,3 @@ struct ClerkInlineProfileWrapperView: View { } } } - diff --git a/packages/expo/ios/templates/ClerkViewFactory.swift b/packages/expo/ios/templates/ClerkViewFactory.swift index 0a96fcdf86c..000d381542e 100644 --- a/packages/expo/ios/templates/ClerkViewFactory.swift +++ b/packages/expo/ios/templates/ClerkViewFactory.swift @@ -16,6 +16,16 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { private static let clerkLoadMaxAttempts = 30 private static let clerkLoadIntervalNs: UInt64 = 100_000_000 + private static var clerkConfigured = false + + /// 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? { + if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty { + return custom + } + return Bundle.main.bundleIdentifier + } private init() {} @@ -30,12 +40,38 @@ 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() } - Clerk.configure(publishableKey: publishableKey) + // 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 + if let service = Self.keychainService { + Clerk.configure( + publishableKey: publishableKey, + options: .init(keychainConfig: .init(service: service)) + ) + } else { + Clerk.configure(publishableKey: publishableKey) + } // Wait for Clerk to finish loading (cached data + API refresh). // The static configure() fires off async refreshes; poll until loaded. @@ -52,7 +88,7 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { /// 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 service = Bundle.main.bundleIdentifier, !service.isEmpty else { return } + guard let service = keychainService, !service.isEmpty else { return } let jsTokenKey = "__clerk_client_jwt" let nativeTokenKey = "clerkDeviceToken" @@ -97,35 +133,78 @@ 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) { - guard let service = Bundle.main.bundleIdentifier, !service.isEmpty else { return } - - let nativeTokenKey = "clerkDeviceToken" + /// Reads the native device token from keychain, if present. + private static func readNativeDeviceToken() -> String? { + guard let service = keychainService, !service.isEmpty else { return nil } - // Check if native SDK already has a device token — don't overwrite - let checkQuery: [String: Any] = [ + var result: CFTypeRef? + let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, - kSecAttrAccount as String: nativeTokenKey, - kSecReturnData as String: false, + kSecAttrAccount as String: "clerkDeviceToken", + kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] - if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess { - return + 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 } - // Write the provided token as native device token + let nativeTokenKey = "clerkDeviceToken" guard let tokenData = token.data(using: .utf8) else { return } - let writeQuery: [String: Any] = [ + + // Check if native SDK already has a device token + let checkQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: nativeTokenKey, - kSecValueData as String: tokenData, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecReturnData as String: false, + kSecMatchLimit as String: kSecMatchLimitOne, ] - SecItemAdd(writeQuery as CFDictionary, nil) + + if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess { + // 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) + } } public func createAuthViewController( @@ -206,7 +285,7 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { @MainActor public func getSession() async -> [String: Any]? { - guard let session = Clerk.shared.session else { + guard Self.clerkConfigured, let session = Clerk.shared.session else { return nil } @@ -242,8 +321,13 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { @MainActor public func signOut() async throws { - guard let sessionId = Clerk.shared.session?.id else { return } + guard Self.clerkConfigured, let sessionId = Clerk.shared.session?.id else { return } try await Clerk.shared.auth.signOut(sessionId: sessionId) + + // Clear all keychain data (device token, cached client/environment, etc.) + // so the native SDK doesn't boot with a stale token on next launch. + Clerk.clearAllKeychainItems() + Self.clerkConfigured = false } } @@ -396,20 +480,38 @@ struct ClerkInlineAuthWrapperView: View { let dismissable: Bool let onEvent: (String, [String: Any]) -> Void + // Track initial session to detect new sign-ins (same approach as Android) + @State private var initialSessionId: String? = Clerk.shared.session?.id + @State private var eventSent = false + + private func sendAuthCompleted(sessionId: String, type: String) { + guard !eventSent, sessionId != initialSessionId else { return } + eventSent = true + onEvent(type, ["sessionId": sessionId, "type": type == "signUpCompleted" ? "signUp" : "signIn"]) + } + var body: some View { AuthView(mode: mode, isDismissable: dismissable) .environment(Clerk.shared) + // Primary detection: observe Clerk.shared.session directly (matches Android's sessionFlow approach). + // This is more reliable than auth.events which may not emit for inline AuthView sign-ins. + .onChange(of: Clerk.shared.session?.id) { _, newSessionId in + guard let sessionId = newSessionId else { return } + sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") + } + // Fallback: also listen to auth.events for signUp events and edge cases .task { for await event in Clerk.shared.auth.events { + guard !eventSent else { continue } switch event { case .signInCompleted(let signIn): - if let sessionId = signIn.createdSessionId { - onEvent("signInCompleted", ["sessionId": sessionId, "type": "signIn"]) - } + let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id + if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } case .signUpCompleted(let signUp): - if let sessionId = signUp.createdSessionId { - onEvent("signUpCompleted", ["sessionId": sessionId, "type": "signUp"]) - } + let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id + if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signUpCompleted") } + case .sessionChanged(_, let newSession): + if let sessionId = newSession?.id { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } default: break } diff --git a/packages/expo/src/hooks/useUserProfileModal.ts b/packages/expo/src/hooks/useUserProfileModal.ts index d97b8c35b28..da7c6f4d081 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,17 +69,36 @@ 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; + hadNativeSessionBefore = !!(preCheck?.sessionId || preCheck?.session?.id); + + if (!hadNativeSessionBefore) { + const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + if (bearerToken) { + await ClerkExpo.configure(clerk.publishableKey, bearerToken); + + // Re-check if configure produced a session + const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null; + hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id); + } + } + } + await ClerkExpo.presentUserProfile({ dismissable: true, }); - // 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 (!hasNativeSession) { - // Clear native session explicitly (may already be cleared, but ensure it) + if (!hasNativeSession && hadNativeSessionBefore) { try { await ClerkExpo.signOut?.(); } catch (e) { @@ -85,7 +107,6 @@ export function useUserProfileModal(): UseUserProfileModalReturn { } } - // Sign out from JS SDK to update isSignedIn state if (clerk?.signOut) { try { await clerk.signOut(); @@ -97,15 +118,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..4e0795970ff 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,16 +135,40 @@ 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; + hadNativeSessionBefore = !!(preCheck?.sessionId || preCheck?.session?.id); + + if (!hadNativeSessionBefore) { + const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + if (bearerToken) { + await ClerkExpo.configure(clerk.publishableKey, bearerToken); + + // Re-check if configure produced a session + const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null; + hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id); + } + } + } + await ClerkExpo.presentUserProfile({ dismissable: true, }); - // 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 (!hasNativeSession) { + if (!hasNativeSession && hadNativeSessionBefore) { // Clear local state immediately for instant UI feedback setNativeUser(null); @@ -161,25 +187,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..f4445f4ecc3 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,88 @@ 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, + 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) { + hasSyncedRef.current = false; + + // Clear the native session so native components (UserButton, etc.) + // don't continue showing a signed-in state after JS-side sign out. + const ClerkExpo = NativeClerkModule; + if (ClerkExpo?.signOut) { + void ClerkExpo.signOut().catch((error: unknown) => { + if (__DEV__) { + console.warn('[NativeSessionSync] Failed to clear native session:', error); + } + }); + } + + return; + } + + if (hasSyncedRef.current) { + return; + } + + 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) { + hasSyncedRef.current = true; + return; + } + + // Read the JS SDK's client JWT and push it to the native SDK + const bearerToken = (await effectiveTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + if (bearerToken) { + await ClerkExpo.configure(publishableKey, bearerToken); + hasSyncedRef.current = true; + } + } catch (error) { + if (__DEV__) { + console.warn('[NativeSessionSync] Failed to sync JS session to native:', error); + } + } + }; + + void syncToNative(); + }, [isSignedIn, publishableKey, effectiveTokenCache]); + + return null; +} + export function ClerkProvider(props: ClerkProviderProps): JSX.Element { const { children, @@ -100,15 +183,21 @@ export function ClerkProvider(props: ClerkProviderProps(props: ClerkProviderProps { try { if (nativeAuthState.type === 'signedIn' && nativeAuthState.sessionId && clerkInstance.setActive) { + // Copy the native client's bearer token to the JS SDK's token cache + // so API requests use the native client (which has the session). + const ClerkExpo = NativeClerkModule; + if (ClerkExpo?.getClientToken) { + const nativeClientToken = await ClerkExpo.getClientToken(); + if (nativeClientToken) { + const effectiveTokenCache = tokenCache ?? defaultTokenCache; + await effectiveTokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); + } + } + // Ensure the session exists in the client before calling setActive const sessionInClient = clerkInstance.client?.sessions?.some( (s: { id: string }) => s.id === nativeAuthState.sessionId, @@ -293,6 +393,12 @@ export function ClerkProvider(props: ClerkProviderProps + {isNative() && ( + + )} {children} ); From 4685f92e53a8e093a32a442170af09204d487eee Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Mon, 16 Mar 2026 09:42:14 -0700 Subject: [PATCH 2/8] remove unrelated file --- cross-platform-components-response.md | 42 --------------------------- 1 file changed, 42 deletions(-) delete mode 100644 cross-platform-components-response.md diff --git a/cross-platform-components-response.md b/cross-platform-components-response.md deleted file mode 100644 index a228b320bcc..00000000000 --- a/cross-platform-components-response.md +++ /dev/null @@ -1,42 +0,0 @@ -Hey Bryce, yeah I've been thinking about this. Here's where my head is at: - -**Short answer:** Yes, we can support cross-platform components with a unified API, and our current architecture already supports it — no native rewrites needed. But I think Replit's immediate blockers are actually simpler than that. - -**What Replit probably needs right now** - -Looking at Yu's thread, their setup is: programmatically generated apps via Platform API, their own sign-in UI, web preview in Replit + deploy to native, one codebase/one set of keys. I don't think they need our prebuilt native components — they need the headless hooks (`useSignIn`, `useSignUp`, `useSSO`) to work reliably across web and native, which they mostly do today. - -Their two actual blockers are bugs on our side: - -1. **`@clerk/expo` crashes in web view** — Yu showed this in the video. The old `@clerk/clerk-expo` works fine but v3 tries to initialize TurboModules that don't exist in web mode. This is a targeted fix in our ClerkProvider initialization path. - -2. **`proxyUrl` is silently ignored on native** — I dug into this and found that `proxyUrl` is passed to `ClerkReactProvider` (web side) but never forwarded to the native Clerk instance in `getClerkInstance()`. The `BuildClerkOptions` type doesn't even have a `proxyUrl` field. This is why Yu's Google SSO works on dev (no proxy) but breaks on prod (with proxy). Also a targeted fix. - -We should prioritize fixing those two — they're both small/scoped and unblock Replit immediately. - -**On cross-platform components as a broader initiative** - -The architecture already has the building blocks: - -- `tsconfig` has `moduleSuffixes: [".web", ".ios", ".android", ".native", ""]` so the bundler auto-selects platform files -- Native components (`AuthView`, `UserButton`, `UserProfileView`) already have fallback paths when native modules aren't available -- The `WrapComponent` pattern in `uiComponents.tsx` already does platform switching (currently throws on native instead of rendering the native component) - -A unified component would just be thin glue files: - -``` -src/components/SignIn.native.tsx → renders -src/components/SignIn.web.tsx → renders from @clerk/react -``` - -I'd call it **medium effort** — the implementation per component is ~50 lines of glue code, but the real cost is API design (deciding what's exposed, what's platform-specific, what gets silently ignored) and testing across iOS/Android/web. - -One caveat: native components require the Expo config plugin + prebuild, so they won't work in Expo Go. Worth confirming with Yu whether they're using Expo Go or dev builds — and more broadly, whether they even want our prebuilt components or just the headless hooks with their own UI. - -**Suggested next steps:** - -1. Fix the web mode crash and `proxyUrl` bug — unblocks Replit now -2. Ask Yu: "Are you building custom sign-in UI or do you need our prebuilt components?" — clarifies if the component work matters for them -3. If there's appetite, scope out the unified component API as a follow-up initiative for the broader Expo community - -Happy to dig deeper on any of this. From 4bdb301fe86146bab01bacb3af4fd54b4989e2c5 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 24 Mar 2026 10:16:48 -0700 Subject: [PATCH 3/8] fix(expo): wait for session hydration after cold-start configure() On cold start with a bearer token, configure() resolved as soon as Clerk.isInitialized flipped. Callers like useUserProfileModal and UserButton that immediately call getSession() could observe null. Now we wait for Clerk.sessionFlow to emit a non-null session (up to 5s) before resolving, matching the already-initialized path. --- .../src/main/java/expo/modules/clerk/ClerkExpoModule.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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 efd132d7633..f7340207689 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 @@ -85,6 +85,13 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : withTimeout(10_000L) { Clerk.isInitialized.first { it } } + // If a bearer token was provided, wait for the session to hydrate + // so callers that immediately call getSession() see the session. + if (!bearerToken.isNullOrEmpty()) { + withTimeout(5_000L) { + Clerk.sessionFlow.first { it != null } + } + } } catch (e: TimeoutCancellationException) { val initError = Clerk.initializationError.value val message = if (initError != null) { From c41f47ac1217b9358a5c397d5a986d5e9622a471 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 24 Mar 2026 10:35:00 -0700 Subject: [PATCH 4/8] fix(expo): bump clerk-android to 1.0.10 for updateDeviceToken API The session sync code uses Clerk.updateDeviceToken() which was added in clerk-android 1.0.10. Bumps from 1.0.9 to 1.0.10. --- packages/expo/android/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle index 5c0a26072f3..6ebe38cacea 100644 --- a/packages/expo/android/build.gradle +++ b/packages/expo/android/build.gradle @@ -18,8 +18,8 @@ ext { credentialsVersion = "1.3.0" googleIdVersion = "1.1.1" kotlinxCoroutinesVersion = "1.7.3" - clerkAndroidApiVersion = "1.0.9" - clerkAndroidUiVersion = "1.0.9" + clerkAndroidApiVersion = "1.0.10" + clerkAndroidUiVersion = "1.0.10" composeVersion = "1.7.0" activityComposeVersion = "1.9.0" lifecycleVersion = "2.8.0" From c26e1ce2cef6d4a462a2418b3857c4d710913f2f Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 24 Mar 2026 10:37:42 -0700 Subject: [PATCH 5/8] fix(expo): address stale token and protocol conformance issues - Android: clear DEVICE_TOKEN from SharedPreferences in signOut() even when native SDK is not initialized, preventing stale token on next init - iOS: signOut() now always clears keychain data and resets state, even when there is no active native session - iOS templates: add missing getClientToken() for protocol conformance, fix signOut() to always clear keychain --- .../java/expo/modules/clerk/ClerkExpoModule.kt | 7 ++++++- packages/expo/ios/ClerkViewFactory.swift | 10 ++++++---- packages/expo/ios/templates/ClerkViewFactory.swift | 14 ++++++++++---- 3 files changed, 22 insertions(+), 9 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 f7340207689..e4d15f6a963 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 @@ -259,7 +259,12 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun signOut(promise: Promise) { if (!Clerk.isInitialized.value) { - // Resolve gracefully when not initialized (matches iOS behavior) + // Clear DEVICE_TOKEN from SharedPreferences even when not initialized, + // so the next Clerk.initialize() doesn't boot with a stale client token. + reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) + .edit() + .remove("DEVICE_TOKEN") + .apply() promise.resolve(null) return } diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index c2c5327b7f0..58bfdb7d418 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -206,11 +206,13 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { @MainActor public func signOut() async throws { - guard let sessionId = Clerk.shared.session?.id else { return } - try await Clerk.shared.auth.signOut(sessionId: sessionId) + if let sessionId = Clerk.shared.session?.id { + try await Clerk.shared.auth.signOut(sessionId: sessionId) + } - // Clear all keychain data (device token, cached client/environment, etc.) - // so the native SDK doesn't boot with a stale token on next launch. + // Always clear keychain data and reset state, even when there's no active + // session. This ensures the native SDK doesn't boot with a stale token + // on next launch (e.g. JS signed out before native had a session). Clerk.clearAllKeychainItems() Self.clerkConfigured = false } diff --git a/packages/expo/ios/templates/ClerkViewFactory.swift b/packages/expo/ios/templates/ClerkViewFactory.swift index 000d381542e..ab2cfb88ace 100644 --- a/packages/expo/ios/templates/ClerkViewFactory.swift +++ b/packages/expo/ios/templates/ClerkViewFactory.swift @@ -207,6 +207,10 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { } } + public func getClientToken() -> String? { + Self.readNativeDeviceToken() + } + public func createAuthViewController( mode: String, dismissable: Bool, @@ -321,11 +325,13 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { @MainActor public func signOut() async throws { - guard Self.clerkConfigured, let sessionId = Clerk.shared.session?.id else { return } - try await Clerk.shared.auth.signOut(sessionId: sessionId) + if let sessionId = Clerk.shared.session?.id { + try await Clerk.shared.auth.signOut(sessionId: sessionId) + } - // Clear all keychain data (device token, cached client/environment, etc.) - // so the native SDK doesn't boot with a stale token on next launch. + // Always clear keychain data and reset state, even when there's no active + // session. This ensures the native SDK doesn't boot with a stale token + // on next launch (e.g. JS signed out before native had a session). Clerk.clearAllKeychainItems() Self.clerkConfigured = false } From e0c2b622dfb44d5dd76f3fb62b6244e63101be65 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 24 Mar 2026 10:47:46 -0700 Subject: [PATCH 6/8] fix(expo): add clerkConfigured guard to getSession() in ClerkViewFactory Match the template's guard so getSession() returns nil after signOut() resets clerkConfigured, preventing stale session data. --- packages/expo/ios/ClerkViewFactory.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index 58bfdb7d418..5990a5bfd03 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -198,7 +198,7 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { @MainActor public func getSession() async -> [String: Any]? { - guard let session = Clerk.shared.session else { + guard Self.clerkConfigured, let session = Clerk.shared.session else { return nil } return Self.sessionPayload(from: session, user: session.user ?? Clerk.shared.user) From df51c1e77ce1100d5b3246b8945bc47eaa93e72e Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Wed, 25 Mar 2026 09:47:26 -0700 Subject: [PATCH 7/8] fix(expo): guard signOut() against uninitialized Clerk.shared on iOS Clerk.shared force-unwraps and crashes if accessed before configure(). The JS provider calls signOut() on native before Clerk is initialized, causing an immediate crash on launch. Guard all Clerk.shared access behind Self.clerkConfigured check. --- packages/expo/ios/ClerkViewFactory.swift | 12 +++++------- packages/expo/ios/templates/ClerkViewFactory.swift | 12 +++++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index 5990a5bfd03..29e6c1db0ee 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -206,14 +206,12 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { @MainActor public func signOut() async throws { - if let sessionId = Clerk.shared.session?.id { - try await Clerk.shared.auth.signOut(sessionId: sessionId) + if Self.clerkConfigured { + if let sessionId = Clerk.shared.session?.id { + try await Clerk.shared.auth.signOut(sessionId: sessionId) + } + Clerk.clearAllKeychainItems() } - - // Always clear keychain data and reset state, even when there's no active - // session. This ensures the native SDK doesn't boot with a stale token - // on next launch (e.g. JS signed out before native had a session). - Clerk.clearAllKeychainItems() Self.clerkConfigured = false } diff --git a/packages/expo/ios/templates/ClerkViewFactory.swift b/packages/expo/ios/templates/ClerkViewFactory.swift index ab2cfb88ace..2086f9eba81 100644 --- a/packages/expo/ios/templates/ClerkViewFactory.swift +++ b/packages/expo/ios/templates/ClerkViewFactory.swift @@ -325,14 +325,12 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { @MainActor public func signOut() async throws { - if let sessionId = Clerk.shared.session?.id { - try await Clerk.shared.auth.signOut(sessionId: sessionId) + if Self.clerkConfigured { + if let sessionId = Clerk.shared.session?.id { + try await Clerk.shared.auth.signOut(sessionId: sessionId) + } + Clerk.clearAllKeychainItems() } - - // Always clear keychain data and reset state, even when there's no active - // session. This ensures the native SDK doesn't boot with a stale token - // on next launch (e.g. JS signed out before native had a session). - Clerk.clearAllKeychainItems() Self.clerkConfigured = false } } From dd96357b5ed7dfb3cb0ad10b746ead1066fe888a Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Wed, 25 Mar 2026 10:09:49 -0700 Subject: [PATCH 8/8] fix(expo): use defer to guarantee keychain cleanup on signOut If auth.signOut() throws (e.g. network error), clearAllKeychainItems() was skipped, leaving stale tokens. Using defer ensures keychain is always cleared regardless of whether the remote sign-out succeeds. --- packages/expo/ios/ClerkViewFactory.swift | 2 +- packages/expo/ios/templates/ClerkViewFactory.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index 29e6c1db0ee..38b64c29edb 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -207,10 +207,10 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { @MainActor public func signOut() async throws { if Self.clerkConfigured { + defer { Clerk.clearAllKeychainItems() } if let sessionId = Clerk.shared.session?.id { try await Clerk.shared.auth.signOut(sessionId: sessionId) } - Clerk.clearAllKeychainItems() } Self.clerkConfigured = false } diff --git a/packages/expo/ios/templates/ClerkViewFactory.swift b/packages/expo/ios/templates/ClerkViewFactory.swift index 2086f9eba81..d9048643f9a 100644 --- a/packages/expo/ios/templates/ClerkViewFactory.swift +++ b/packages/expo/ios/templates/ClerkViewFactory.swift @@ -326,10 +326,10 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { @MainActor public func signOut() async throws { if Self.clerkConfigured { + defer { Clerk.clearAllKeychainItems() } if let sessionId = Clerk.shared.session?.id { try await Clerk.shared.auth.signOut(sessionId: sessionId) } - Clerk.clearAllKeychainItems() } Self.clerkConfigured = false }