diff --git a/.changeset/fix-inline-authview-sso-oauth.md b/.changeset/fix-inline-authview-sso-oauth.md new file mode 100644 index 00000000000..4501a4e0f0b --- /dev/null +++ b/.changeset/fix-inline-authview-sso-oauth.md @@ -0,0 +1,7 @@ +--- +'@clerk/expo': patch +--- + +- 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. 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) { diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index f1fa57788a5..d5c7809320b 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,113 @@ 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 + // Try to present immediately. Only wait if a previous modal is dismissing. + presentWhenReady(authVC, attempts: 0) } - private func findViewController() -> UIViewController? { - var responder: UIResponder? = self - while let nextResponder = responder?.next { - if let vc = nextResponder as? UIViewController { - return vc + private func dismissAuthModal() { + presentedAuthVC?.dismiss(animated: false) + 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) } - responder = nextResponder + return } - return nil + + // 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 } - override public func layoutSubviews() { - super.layoutSubviews() - hostingController?.view.frame = bounds + 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 top + } + + 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])) + } } }