Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-inline-authview-sso-oauth.md
Original file line number Diff line number Diff line change
@@ -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 `<AuthView>` component.
- Fix Android `<AuthView>` getting stuck on the "Get help" screen after sign out via `<UserProfileView>`.
- Fix a brief white flash when the inline `<AuthView>` first mounts on iOS.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -72,23 +84,32 @@ 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")

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"
Expand All @@ -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,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
151 changes: 105 additions & 46 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}

Expand All @@ -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)
}
}
}

Expand Down
7 changes: 6 additions & 1 deletion packages/expo/ios/ClerkViewFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,12 @@ class ClerkAuthWrapperViewController: UIHostingController<ClerkAuthWrapperView>
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]))
}
}
}

Expand Down
Loading