From dd27ce7e4e1b91336071f5f4192c0bab2739f636 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Mon, 6 Apr 2026 11:53:00 -0700 Subject: [PATCH 1/8] fix(expo): present inline AuthView as modal to fix OAuth from forgot-password The inline AuthView embedded as a child UIHostingController in React Native's view hierarchy disrupts ASWebAuthenticationSession callbacks during OAuth flows. SSO from the forgot-password screen would silently fail because the OAuth callback couldn't properly update Clerk.shared.client in the embedded context. This changes ClerkAuthNativeView to present the AuthView as a full-screen modal (matching the working presentAuth() behavior) instead of embedding it inline. Also adds retry logic for modal presentation after sign-out to handle cases where a previous modal (e.g., UserProfileView) is still dismissing. --- packages/expo/ios/ClerkExpoModule.swift | 135 +++++++++++++++-------- packages/expo/ios/ClerkViewFactory.swift | 7 +- 2 files changed, 95 insertions(+), 47 deletions(-) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index f1fa57788a5..c0a375c427c 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -219,24 +219,35 @@ class ClerkExpoModule: RCTEventEmitter { // MARK: - Inline View: ClerkAuthNativeView public class ClerkAuthNativeView: UIView { - private var hostingController: UIViewController? private var currentMode: String = "signInOrUp" private var currentDismissable: Bool = true private var hasInitialized: Bool = false + private var authEventSent: Bool = false + private var presentedAuthVC: UIViewController? @objc var onAuthEvent: RCTBubblingEventBlock? @objc var mode: NSString? { didSet { - currentMode = (mode as String?) ?? "signInOrUp" - if hasInitialized { updateView() } + let newMode = (mode as String?) ?? "signInOrUp" + guard newMode != currentMode else { return } + currentMode = newMode + if hasInitialized { + dismissAuthModal() + presentAuthModal() + } } } @objc var isDismissable: NSNumber? { didSet { - currentDismissable = isDismissable?.boolValue ?? true - if hasInitialized { updateView() } + let newDismissable = isDismissable?.boolValue ?? true + guard newDismissable != currentDismissable else { return } + currentDismissable = newDismissable + if hasInitialized { + dismissAuthModal() + presentAuthModal() + } } } @@ -252,65 +263,97 @@ public class ClerkAuthNativeView: UIView { super.didMoveToWindow() if window != nil && !hasInitialized { hasInitialized = true - updateView() + presentAuthModal() } } - private func updateView() { - // Remove old hosting controller - hostingController?.view.removeFromSuperview() - hostingController?.removeFromParent() - hostingController = nil + override public func removeFromSuperview() { + dismissAuthModal() + super.removeFromSuperview() + } + + // MARK: - Modal Presentation + // + // The AuthView is presented as a real modal rather than embedded inline. + // Embedding a UIHostingController as a child of a React Native view disrupts + // ASWebAuthenticationSession callbacks during OAuth flows (e.g., SSO from the + // forgot-password screen). Modal presentation provides an isolated SwiftUI + // lifecycle that handles all OAuth flows correctly. + private func presentAuthModal() { guard let factory = clerkViewFactory else { return } - guard let returnedController = factory.createAuthView( + guard let authVC = factory.createAuthViewController( mode: currentMode, dismissable: currentDismissable, - onEvent: { [weak self] eventName, data in - // Convert data dict to JSON string for codegen event - 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) + completion: { [weak self] result in + guard let self = self, !self.authEventSent else { return } + switch result { + case .success(let data): + if let _ = data["cancelled"] { + // User dismissed — don't send auth event + return + } + self.authEventSent = true + self.sendAuthEvent(type: "signInCompleted", data: data) + case .failure: + break } } ) else { return } - // Attach the returned UIHostingController as a child to preserve SwiftUI lifecycle - if let parentVC = findViewController() { - parentVC.addChild(returnedController) - returnedController.view.frame = bounds - returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - addSubview(returnedController.view) - returnedController.didMove(toParent: parentVC) - hostingController = returnedController - } else { - returnedController.view.frame = bounds - returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - addSubview(returnedController.view) - hostingController = returnedController + authVC.modalPresentationStyle = .fullScreen + + // Wait for any in-flight modal dismissals (e.g., UserProfileView sign-out) + // before presenting. Retry until the top VC has no presented VC. + func tryPresent(attempts: Int = 0) { + guard attempts < 10 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + guard let self = self, self.presentedAuthVC == nil else { return } + guard let rootVC = Self.topViewController() else { + tryPresent(attempts: attempts + 1) + return + } + if rootVC.isBeingDismissed || rootVC.isBeingPresented { + tryPresent(attempts: attempts + 1) + return + } + rootVC.present(authVC, animated: false) + self.presentedAuthVC = authVC + } } + tryPresent() } - private func findViewController() -> UIViewController? { - var responder: UIResponder? = self - while let nextResponder = responder?.next { - if let vc = nextResponder as? UIViewController { - return vc - } - responder = nextResponder + private func dismissAuthModal() { + presentedAuthVC?.dismiss(animated: false) + presentedAuthVC = nil + } + + private static func topViewController() -> UIViewController? { + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }), + let rootVC = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController + else { return nil } + + var top = rootVC + while let presented = top.presentedViewController { + top = presented } - return nil + return top } - override public func layoutSubviews() { - super.layoutSubviews() - hostingController?.view.frame = bounds + private func sendAuthEvent(type: String, data: [String: Any]) { + let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data() + let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" + onAuthEvent?(["type": type, "data": jsonString]) + + // Also emit module-level event so ClerkProvider's useNativeAuthEvents picks it up + if type == "signInCompleted" || type == "signUpCompleted" { + let sessionId = data["sessionId"] as? String + ClerkExpoModule.emitAuthStateChange(type: "signedIn", sessionId: sessionId) + } } } diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index 38b64c29edb..0987014034b 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -348,7 +348,12 @@ class ClerkAuthWrapperViewController: UIHostingController override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) if isBeingDismissed { - completeOnce(.success(["cancelled": true])) + // Check if auth completed (session exists) vs user cancelled + if let session = Clerk.shared.session, session.id != initialSessionId { + completeOnce(.success(["sessionId": session.id, "type": "signIn"])) + } else { + completeOnce(.success(["cancelled": true])) + } } } From d950405562551fcc319a7a2386fccb07402e240a Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 7 Apr 2026 11:34:05 -0700 Subject: [PATCH 2/8] fix(expo): present auth modal immediately, no fixed delay Replace the unconditional 0.3s asyncAfter delay with presentWhenReady(), which presents the auth modal synchronously when possible. The fixed delay caused a visible white flash on initial mount because ClerkAuthNativeView is an empty UIView while waiting to present. When a previous modal (e.g., UserProfileView sign-out) is still dismissing, use UIViewController.transitionCoordinator to wait for the animation to complete instead of polling. Falls back to a one-frame DispatchQueue.main.async retry only when no coordinator is available yet. --- packages/expo/ios/ClerkExpoModule.swift | 56 ++++++++++++++++--------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index c0a375c427c..d5c7809320b 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -303,26 +303,8 @@ public class ClerkAuthNativeView: UIView { ) else { return } authVC.modalPresentationStyle = .fullScreen - - // Wait for any in-flight modal dismissals (e.g., UserProfileView sign-out) - // before presenting. Retry until the top VC has no presented VC. - func tryPresent(attempts: Int = 0) { - guard attempts < 10 else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - guard let self = self, self.presentedAuthVC == nil else { return } - guard let rootVC = Self.topViewController() else { - tryPresent(attempts: attempts + 1) - return - } - if rootVC.isBeingDismissed || rootVC.isBeingPresented { - tryPresent(attempts: attempts + 1) - return - } - rootVC.present(authVC, animated: false) - self.presentedAuthVC = authVC - } - } - tryPresent() + // Try to present immediately. Only wait if a previous modal is dismissing. + presentWhenReady(authVC, attempts: 0) } private func dismissAuthModal() { @@ -330,6 +312,40 @@ public class ClerkAuthNativeView: UIView { presentedAuthVC = nil } + /// Presents the auth view controller as soon as it's safe to do so. + /// On initial mount this presents synchronously (no delay, no white flash). + /// If a previous modal is still dismissing, waits for its transition coordinator + /// to finish — no fixed delays. + private func presentWhenReady(_ authVC: UIViewController, attempts: Int) { + guard presentedAuthVC == nil, attempts < 30 else { return } + guard let rootVC = Self.topViewController() else { + DispatchQueue.main.async { [weak self] in + self?.presentWhenReady(authVC, attempts: attempts + 1) + } + return + } + + // If a previous modal is animating dismissal, wait for it via the + // transition coordinator instead of a fixed delay. + if let coordinator = rootVC.transitionCoordinator { + coordinator.animate(alongsideTransition: nil) { [weak self] _ in + self?.presentWhenReady(authVC, attempts: attempts + 1) + } + return + } + + // If there's still a presented VC (no coordinator yet), wait one frame. + if rootVC.presentedViewController != nil { + DispatchQueue.main.async { [weak self] in + self?.presentWhenReady(authVC, attempts: attempts + 1) + } + return + } + + rootVC.present(authVC, animated: false) + presentedAuthVC = authVC + } + private static func topViewController() -> UIViewController? { guard let scene = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) From 69f484dff138739c0799be2353607f6d47a2c319 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 7 Apr 2026 13:08:41 -0700 Subject: [PATCH 3/8] chore(expo): add changeset for inline AuthView SSO fix --- .changeset/fix-inline-authview-sso-oauth.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-inline-authview-sso-oauth.md diff --git a/.changeset/fix-inline-authview-sso-oauth.md b/.changeset/fix-inline-authview-sso-oauth.md new file mode 100644 index 00000000000..c06798568d2 --- /dev/null +++ b/.changeset/fix-inline-authview-sso-oauth.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Fix OAuth (SSO) sign-in from the forgot-password screen when using the inline `` component on iOS. Previously, embedding the AuthView's `UIHostingController` as a child of a React Native view disrupted `ASWebAuthenticationSession` callbacks, causing OAuth flows initiated from the forgot-password screen to silently fail. The inline view now presents its hosting controller via `UIViewController.present()` so the OAuth callback chain completes correctly. Visual appearance is unchanged. From 077b011fb068198e6f9a382d23dda120c48f33ba Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 7 Apr 2026 17:59:19 -0700 Subject: [PATCH 4/8] fix(expo): clean Android sign-out state to prevent stuck Get Help screen Three related Android fixes for the inline AuthView: 1. Detect new sign-ins by session ID change (not null-to-value). ClerkAuthExpoView's initialSessionId is captured at construction, but the view can be instantiated before signOut has finished clearing local state, causing it to capture a stale session ID. Switching to ID inequality lets subsequent sign-ins fire the auth-completed event correctly. 2. Per-view ViewModelStore for ClerkAuthExpoView. The clerk-android AuthView's navigation ViewModel was scoped to the MainActivity, so its navigation state (e.g. "Get help" destination) persisted across mount/unmount cycles within the same activity. Each ClerkAuthExpoView instance now provides its own ViewModelStoreOwner so the AuthView gets a fresh ViewModel scope per mount. 3. Refresh client from server after sign-out. Clerk.auth.signOut() only clears the active session, not the in-progress Clerk.client.signIn. After sign-out (whether via the JS bridge signOut(), the inline UserProfile view, or the modal UserProfile activity), call Client.getSkippingClientId() to fetch a brand-new client. The skipping variant is required because Client.get() echoes back the same client_id header, returning the same client with the stale signIn still attached. --- .../expo/modules/clerk/ClerkAuthExpoView.kt | 33 ++++++++++++++++--- .../expo/modules/clerk/ClerkExpoModule.kt | 12 +++++++ .../modules/clerk/ClerkUserProfileActivity.kt | 13 +++++++- .../modules/clerk/ClerkUserProfileExpoView.kt | 12 +++++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt index 60280542e27..80811d1fa85 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt @@ -15,6 +15,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.AndroidUiDispatcher import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.setViewTreeLifecycleOwner @@ -44,6 +46,16 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) { private val activity: ComponentActivity? = findActivity(context) + // Per-view ViewModelStoreOwner so the AuthView's ViewModels (including its + // navigation state) are scoped to THIS view instance, not the activity. + // Without this, the AuthView's navigation persists across mount/unmount + // cycles within the same activity, leaving the user stuck on whatever screen + // (e.g. "Get help") was last navigated to before sign-out. + private val viewModelStoreOwner = object : ViewModelStoreOwner { + private val store = ViewModelStore() + override val viewModelStore: ViewModelStore = store + } + private var recomposer: Recomposer? = null private var recomposerJob: kotlinx.coroutines.Job? = null @@ -72,11 +84,17 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) { override fun onDetachedFromWindow() { recomposer?.cancel() recomposerJob?.cancel() + // Clear our per-view ViewModelStore so any AuthView ViewModels are GC'd. + viewModelStoreOwner.viewModelStore.clear() super.onDetachedFromWindow() } - // Track the initial session to detect new sign-ins + // Track the initial session to detect new sign-ins. Captured at construction + // time, but may capture a stale session if the view is mounted before signOut + // has finished clearing local state — so the LaunchedEffect below uses + // session id inequality (not null-to-value) to detect new sign-ins. private var initialSessionId: String? = Clerk.session?.id + private var authCompletedSent: Boolean = false fun setupView() { debugLog(TAG, "setupView - mode: $mode, isDismissable: $isDismissable, activity: $activity") @@ -84,11 +102,14 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) { composeView.setContent { val session by Clerk.sessionFlow.collectAsStateWithLifecycle() - // Detect auth completion: session appeared when there wasn't one + // Detect auth completion: any session that's different from the one we + // started with (captures fresh sign-ins, sign-in-after-sign-out, etc.) LaunchedEffect(session) { val currentSession = session - if (currentSession != null && initialSessionId == null) { - debugLog(TAG, "Auth completed - session present: true") + val currentId = currentSession?.id + if (currentSession != null && currentId != initialSessionId && !authCompletedSent) { + debugLog(TAG, "Auth completed - new session: $currentId (initial: $initialSessionId)") + authCompletedSent = true sendEvent("signInCompleted", mapOf( "sessionId" to currentSession.id, "type" to "signIn" @@ -113,7 +134,9 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) { if (activity != null) { CompositionLocalProvider( - LocalViewModelStoreOwner provides activity, + // Per-view ViewModelStore so AuthView's navigation state doesn't + // leak between mounts within the same MainActivity lifetime. + LocalViewModelStoreOwner provides viewModelStoreOwner, LocalLifecycleOwner provides activity, LocalSavedStateRegistryOwner provides activity, ) { 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 e4d15f6a963..457eb1f2b1d 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.model.client.Client import com.clerk.api.network.serialization.ClerkResult import com.facebook.react.bridge.ActivityEventListener import com.facebook.react.bridge.Promise @@ -272,6 +273,17 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : coroutineScope.launch { try { Clerk.auth.signOut() + // After sign-out, fetch a brand-new client from the server, + // skipping the in-memory client_id header. Without skipping, + // the server echoes back the SAME client (with the previous + // user's in-progress signIn still attached), and AuthView + // re-mounts into the "Get help" fallback because the stale + // signIn's status has no startingFirstFactor. + try { + Client.getSkippingClientId() + } catch (e: Exception) { + debugLog(TAG, "Client.getSkippingClientId() after signOut failed: ${e.message}") + } promise.resolve(null) } catch (e: Exception) { promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign out failed", e) diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt index db96f1a9097..f68b4e30bd8 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.clerk.api.Clerk +import com.clerk.api.network.model.client.Client import com.clerk.ui.userprofile.UserProfileView /** @@ -71,7 +72,17 @@ class ClerkUserProfileActivity : ComponentActivity() { // Detect sign-out: if we had a session and now it's null, user signed out LaunchedEffect(session) { if (hadSession && session == null) { - debugLog(TAG, "Sign-out detected - session became null, dismissing activity") + debugLog(TAG, "Sign-out detected - session became null") + // Fetch a brand-new client from the server, skipping the in-memory + // client_id header. Without skipping, the server echoes back the SAME + // client (with the previous user's in-progress signIn still attached), + // and the AuthView re-mounts into the "Get help" fallback because the + // stale signIn's status has no startingFirstFactor. + try { + Client.getSkippingClientId() + } catch (e: Exception) { + Log.w(TAG, "Client.getSkippingClientId() after UserProfile sign-out failed: ${e.message}") + } finishWithSuccess() } // Update hadSession if we get a session (handles edge cases) diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt index dd770bee4f5..8d3762a3be6 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.savedstate.compose.LocalSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.clerk.api.Clerk +import com.clerk.api.network.model.client.Client import com.clerk.ui.userprofile.UserProfileView import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactContext @@ -77,6 +78,17 @@ class ClerkUserProfileNativeView(context: Context) : FrameLayout(context) { LaunchedEffect(session) { if (hadSession && session == null) { Log.d(TAG, "Sign-out detected") + // Refresh the client from the server to clear any stale in-progress + // signIn/signUp state. Without this, when the AuthView re-mounts after + // sign-out it routes to the "Get help" fallback because the previous + // user's signIn is still in Clerk.client. Clerk.auth.signOut() (called + // internally by UserProfileView) only clears session/user state, not + // the in-progress signIn. + try { + Client.getSkippingClientId() + } catch (e: Exception) { + Log.w(TAG, "Client.getSkippingClientId() after UserProfile sign-out failed: ${e.message}") + } sendEvent("signedOut", emptyMap()) } if (session != null) { From 8a0597e3ce957e436c9c6e5f9394c3c66bc8ff41 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 7 Apr 2026 18:06:22 -0700 Subject: [PATCH 5/8] chore(expo): update changeset to bullet format --- .changeset/fix-inline-authview-sso-oauth.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-inline-authview-sso-oauth.md b/.changeset/fix-inline-authview-sso-oauth.md index c06798568d2..4501a4e0f0b 100644 --- a/.changeset/fix-inline-authview-sso-oauth.md +++ b/.changeset/fix-inline-authview-sso-oauth.md @@ -2,4 +2,6 @@ '@clerk/expo': patch --- -Fix OAuth (SSO) sign-in from the forgot-password screen when using the inline `` component on iOS. Previously, embedding the AuthView's `UIHostingController` as a child of a React Native view disrupted `ASWebAuthenticationSession` callbacks, causing OAuth flows initiated from the forgot-password screen to silently fail. The inline view now presents its hosting controller via `UIViewController.present()` so the OAuth callback chain completes correctly. Visual appearance is unchanged. +- Fix iOS OAuth (SSO) sign-in failing silently when initiated from the forgot password screen of the inline `` component. +- Fix Android `` getting stuck on the "Get help" screen after sign out via ``. +- Fix a brief white flash when the inline `` first mounts on iOS. From 2e18123a203860ae5db8dfd109cb8d12f5780ce4 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Fri, 10 Apr 2026 11:31:56 -0700 Subject: [PATCH 6/8] fix(expo): use SDK API for client token and delegate sign-out cleanup - Bump clerk-android to 1.0.11 which handles Client.getSkippingClientId() in SignOutService.signOut() (clerk/clerk-android#587) - Remove our bridge-level getSkippingClientId() calls (now redundant) - Use Clerk.getDeviceToken() instead of raw SharedPreferences read for getClientToken(), fixing compatibility with encrypted storage in 1.0.11 --- packages/expo/android/build.gradle | 4 ++-- .../expo/modules/clerk/ClerkExpoModule.kt | 20 ++++++------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle index c9feacf08c9..844516f7efc 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.10" - clerkAndroidUiVersion = "1.0.10" + clerkAndroidApiVersion = "1.0.11" + clerkAndroidUiVersion = "1.0.11" composeVersion = "1.7.0" activityComposeVersion = "1.9.0" lifecycleVersion = "2.8.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 457eb1f2b1d..7f29b1bfac0 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,7 +5,6 @@ import android.content.Context import android.content.Intent import android.util.Log import com.clerk.api.Clerk -import com.clerk.api.network.model.client.Client import com.clerk.api.network.serialization.ClerkResult import com.facebook.react.bridge.ActivityEventListener import com.facebook.react.bridge.Promise @@ -246,8 +245,10 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun getClientToken(promise: Promise) { try { - val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) - val deviceToken = prefs.getString("DEVICE_TOKEN", null) + // Use the SDK's public API which handles encrypted storage transparently. + // Direct SharedPreferences reads break on clerk-android >= 1.0.11 where + // DEVICE_TOKEN is encrypted via StorageCipher. + val deviceToken = Clerk.getDeviceToken() promise.resolve(deviceToken) } catch (e: Exception) { debugLog(TAG, "getClientToken failed: ${e.message}") @@ -273,17 +274,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : coroutineScope.launch { try { Clerk.auth.signOut() - // After sign-out, fetch a brand-new client from the server, - // skipping the in-memory client_id header. Without skipping, - // the server echoes back the SAME client (with the previous - // user's in-progress signIn still attached), and AuthView - // re-mounts into the "Get help" fallback because the stale - // signIn's status has no startingFirstFactor. - try { - Client.getSkippingClientId() - } catch (e: Exception) { - debugLog(TAG, "Client.getSkippingClientId() after signOut failed: ${e.message}") - } + // Client refresh after sign-out is handled by the clerk-android + // SDK (SignOutService.signOut calls Client.getSkippingClientId). promise.resolve(null) } catch (e: Exception) { promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign out failed", e) From 2b7d2850ab4553d5be3187d49d2c0c891a0ce698 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Fri, 10 Apr 2026 12:02:55 -0700 Subject: [PATCH 7/8] fix(expo): invalidate pending presentWhenReady callbacks on unmount Addresses Sean's review: removeFromSuperview() clears presentedAuthVC but queued DispatchQueue.main.async and transitionCoordinator callbacks from presentWhenReady() can still fire. The nil presentedAuthVC passes the guard, causing present() on an orphaned auth modal. Adding an isInvalidated flag checked at the top of presentWhenReady() prevents stale callbacks from presenting after the view is removed. --- packages/expo/ios/ClerkExpoModule.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index d5c7809320b..efd1e142445 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -224,6 +224,7 @@ public class ClerkAuthNativeView: UIView { private var hasInitialized: Bool = false private var authEventSent: Bool = false private var presentedAuthVC: UIViewController? + private var isInvalidated: Bool = false @objc var onAuthEvent: RCTBubblingEventBlock? @@ -268,6 +269,7 @@ public class ClerkAuthNativeView: UIView { } override public func removeFromSuperview() { + isInvalidated = true dismissAuthModal() super.removeFromSuperview() } @@ -317,7 +319,7 @@ public class ClerkAuthNativeView: UIView { /// If a previous modal is still dismissing, waits for its transition coordinator /// to finish — no fixed delays. private func presentWhenReady(_ authVC: UIViewController, attempts: Int) { - guard presentedAuthVC == nil, attempts < 30 else { return } + guard !isInvalidated, presentedAuthVC == nil, attempts < 30 else { return } guard let rootVC = Self.topViewController() else { DispatchQueue.main.async { [weak self] in self?.presentWhenReady(authVC, attempts: attempts + 1) From 6b1c872c14b8947726c8f3e20448ce660221ce43 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Fri, 10 Apr 2026 14:01:58 -0700 Subject: [PATCH 8/8] fix(expo): pin clerk-android to 1.0.12 --- 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 844516f7efc..db9dbeb177f 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.11" - clerkAndroidUiVersion = "1.0.11" + clerkAndroidApiVersion = "1.0.12" + clerkAndroidUiVersion = "1.0.12" composeVersion = "1.7.0" activityComposeVersion = "1.9.0" lifecycleVersion = "2.8.0"