Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/fix-inline-authview-sso-oauth.md
Original file line numberDiff line numberDiff 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.
4 changes: 2 additions & 2 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.12"
clerkAndroidUiVersion = "1.0.12"
composeVersion = "1.7.0"
activityComposeVersion = "1.9.0"
lifecycleVersion = "2.8.0"
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -245,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}")
Expand All@@ -272,6 +274,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
coroutineScope.launch {
try {
Clerk.auth.signOut()
// 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)
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
153 changes: 107 additions & 46 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,24 +219,36 @@ 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?
private var isInvalidated: Bool = false

@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 +264,114 @@ 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() {
isInvalidated = true
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 !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)
}
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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(expo): inline AuthView OAuth + Android sign-out state cleanup by chriscanin · Pull Request #8260 · clerk/javascript · GitHub
Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/fix-inline-authview-sso-oauth.md
Original file line numberDiff line numberDiff 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.
4 changes: 2 additions & 2 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.12"
clerkAndroidUiVersion = "1.0.12"
composeVersion = "1.7.0"
activityComposeVersion = "1.9.0"
lifecycleVersion = "2.8.0"
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -245,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}")
Expand All@@ -272,6 +274,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
coroutineScope.launch {
try {
Clerk.auth.signOut()
// 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)
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
153 changes: 107 additions & 46 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,24 +219,36 @@ 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?
private var isInvalidated: Bool = false

@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 +264,114 @@ 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() {
isInvalidated = true
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 !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)
}
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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(expo): inline AuthView OAuth + Android sign-out state cleanup by chriscanin · Pull Request #8260 · clerk/javascript · GitHub
Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/fix-inline-authview-sso-oauth.md
Original file line numberDiff line numberDiff 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.
4 changes: 2 additions & 2 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.12"
clerkAndroidUiVersion = "1.0.12"
composeVersion = "1.7.0"
activityComposeVersion = "1.9.0"
lifecycleVersion = "2.8.0"
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -245,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}")
Expand All@@ -272,6 +274,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
coroutineScope.launch {
try {
Clerk.auth.signOut()
// 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)
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
153 changes: 107 additions & 46 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,24 +219,36 @@ 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?
private var isInvalidated: Bool = false

@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 +264,114 @@ 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() {
isInvalidated = true
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 !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)
}
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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(expo): inline AuthView OAuth + Android sign-out state cleanup by chriscanin · Pull Request #8260 · clerk/javascript · GitHub
Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/fix-inline-authview-sso-oauth.md
Original file line numberDiff line numberDiff 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.
4 changes: 2 additions & 2 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.12"
clerkAndroidUiVersion = "1.0.12"
composeVersion = "1.7.0"
activityComposeVersion = "1.9.0"
lifecycleVersion = "2.8.0"
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -245,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}")
Expand All@@ -272,6 +274,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
coroutineScope.launch {
try {
Clerk.auth.signOut()
// 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)
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
153 changes: 107 additions & 46 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,24 +219,36 @@ 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?
private var isInvalidated: Bool = false

@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 +264,114 @@ 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() {
isInvalidated = true
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 !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)
}
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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(expo): inline AuthView OAuth + Android sign-out state cleanup by chriscanin · Pull Request #8260 · clerk/javascript · GitHub
Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/fix-inline-authview-sso-oauth.md
Original file line numberDiff line numberDiff 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.
4 changes: 2 additions & 2 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.12"
clerkAndroidUiVersion = "1.0.12"
composeVersion = "1.7.0"
activityComposeVersion = "1.9.0"
lifecycleVersion = "2.8.0"
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -245,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}")
Expand All@@ -272,6 +274,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
coroutineScope.launch {
try {
Clerk.auth.signOut()
// 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)
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
153 changes: 107 additions & 46 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,24 +219,36 @@ 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?
private var isInvalidated: Bool = false

@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 +264,114 @@ 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() {
isInvalidated = true
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 !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)
}
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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(expo): inline AuthView OAuth + Android sign-out state cleanup by chriscanin · Pull Request #8260 · clerk/javascript · GitHub
Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/fix-inline-authview-sso-oauth.md
Original file line numberDiff line numberDiff 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.
4 changes: 2 additions & 2 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.12"
clerkAndroidUiVersion = "1.0.12"
composeVersion = "1.7.0"
activityComposeVersion = "1.9.0"
lifecycleVersion = "2.8.0"
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -245,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}")
Expand All@@ -272,6 +274,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
coroutineScope.launch {
try {
Clerk.auth.signOut()
// 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)
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
153 changes: 107 additions & 46 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,24 +219,36 @@ 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?
private var isInvalidated: Bool = false

@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 +264,114 @@ 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() {
isInvalidated = true
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 !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)
}
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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(expo): inline AuthView OAuth + Android sign-out state cleanup by chriscanin · Pull Request #8260 · clerk/javascript · GitHub
Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/fix-inline-authview-sso-oauth.md
Original file line numberDiff line numberDiff 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.
4 changes: 2 additions & 2 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.12"
clerkAndroidUiVersion = "1.0.12"
composeVersion = "1.7.0"
activityComposeVersion = "1.9.0"
lifecycleVersion = "2.8.0"
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -245,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}")
Expand All@@ -272,6 +274,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
coroutineScope.launch {
try {
Clerk.auth.signOut()
// 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)
Expand Down
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
153 changes: 107 additions & 46 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,24 +219,36 @@ 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?
private var isInvalidated: Bool = false

@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 +264,114 @@ 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() {
isInvalidated = true
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 !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)
}
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 numberDiff line numberDiff 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