From d1902b91207f79b3af6f598aaf11490f1ceb2149 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Mon, 16 Mar 2026 09:42:10 -0700 Subject: [PATCH 1/2] 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/2] 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.