Skip to content

Commit 2e84152

Browse files
authored
fix(expo): inline AuthView OAuth + Android sign-out state cleanup (#8260)
1 parent 68d1d8d commit 2e84152

8 files changed

Lines changed: 180 additions & 57 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/expo': patch
3+
---
4+
5+
- Fix iOS OAuth (SSO) sign-in failing silently when initiated from the forgot password screen of the inline `<AuthView>` component.
6+
- Fix Android `<AuthView>` getting stuck on the "Get help" screen after sign out via `<UserProfileView>`.
7+
- Fix a brief white flash when the inline `<AuthView>` first mounts on iOS.

packages/expo/android/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ ext {
1818
credentialsVersion = "1.3.0"
1919
googleIdVersion = "1.1.1"
2020
kotlinxCoroutinesVersion = "1.7.3"
21-
clerkAndroidApiVersion = "1.0.10"
22-
clerkAndroidUiVersion = "1.0.10"
21+
clerkAndroidApiVersion = "1.0.12"
22+
clerkAndroidUiVersion = "1.0.12"
2323
composeVersion = "1.7.0"
2424
activityComposeVersion = "1.9.0"
2525
lifecycleVersion = "2.8.0"

packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import androidx.compose.runtime.getValue
1515
import androidx.compose.ui.Modifier
1616
import androidx.compose.ui.platform.AndroidUiDispatcher
1717
import androidx.compose.ui.platform.ComposeView
18+
import androidx.lifecycle.ViewModelStore
19+
import androidx.lifecycle.ViewModelStoreOwner
1820
import androidx.lifecycle.compose.LocalLifecycleOwner
1921
import androidx.lifecycle.compose.collectAsStateWithLifecycle
2022
import androidx.lifecycle.setViewTreeLifecycleOwner
@@ -44,6 +46,16 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) {
4446

4547
private val activity: ComponentActivity? = findActivity(context)
4648

49+
// Per-view ViewModelStoreOwner so the AuthView's ViewModels (including its
50+
// navigation state) are scoped to THIS view instance, not the activity.
51+
// Without this, the AuthView's navigation persists across mount/unmount
52+
// cycles within the same activity, leaving the user stuck on whatever screen
53+
// (e.g. "Get help") was last navigated to before sign-out.
54+
private val viewModelStoreOwner = object : ViewModelStoreOwner {
55+
private val store = ViewModelStore()
56+
override val viewModelStore: ViewModelStore = store
57+
}
58+
4759
private var recomposer: Recomposer? = null
4860
private var recomposerJob: kotlinx.coroutines.Job? = null
4961

@@ -72,23 +84,32 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) {
7284
override fun onDetachedFromWindow() {
7385
recomposer?.cancel()
7486
recomposerJob?.cancel()
87+
// Clear our per-view ViewModelStore so any AuthView ViewModels are GC'd.
88+
viewModelStoreOwner.viewModelStore.clear()
7589
super.onDetachedFromWindow()
7690
}
7791

78-
// Track the initial session to detect new sign-ins
92+
// Track the initial session to detect new sign-ins. Captured at construction
93+
// time, but may capture a stale session if the view is mounted before signOut
94+
// has finished clearing local state — so the LaunchedEffect below uses
95+
// session id inequality (not null-to-value) to detect new sign-ins.
7996
private var initialSessionId: String? = Clerk.session?.id
97+
private var authCompletedSent: Boolean = false
8098

8199
fun setupView() {
82100
debugLog(TAG, "setupView - mode: $mode, isDismissable: $isDismissable, activity: $activity")
83101

84102
composeView.setContent {
85103
val session by Clerk.sessionFlow.collectAsStateWithLifecycle()
86104

87-
// Detect auth completion: session appeared when there wasn't one
105+
// Detect auth completion: any session that's different from the one we
106+
// started with (captures fresh sign-ins, sign-in-after-sign-out, etc.)
88107
LaunchedEffect(session) {
89108
val currentSession = session
90-
if (currentSession != null && initialSessionId == null) {
91-
debugLog(TAG, "Auth completed - session present: true")
109+
val currentId = currentSession?.id
110+
if (currentSession != null && currentId != initialSessionId && !authCompletedSent) {
111+
debugLog(TAG, "Auth completed - new session: $currentId (initial: $initialSessionId)")
112+
authCompletedSent = true
92113
sendEvent("signInCompleted", mapOf(
93114
"sessionId" to currentSession.id,
94115
"type" to "signIn"
@@ -113,7 +134,9 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) {
113134

114135
if (activity != null) {
115136
CompositionLocalProvider(
116-
LocalViewModelStoreOwner provides activity,
137+
// Per-view ViewModelStore so AuthView's navigation state doesn't
138+
// leak between mounts within the same MainActivity lifetime.
139+
LocalViewModelStoreOwner provides viewModelStoreOwner,
117140
LocalLifecycleOwner provides activity,
118141
LocalSavedStateRegistryOwner provides activity,
119142
) {

packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,10 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
245245
@ReactMethod
246246
override fun getClientToken(promise: Promise) {
247247
try {
248-
val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
249-
val deviceToken = prefs.getString("DEVICE_TOKEN", null)
248+
// Use the SDK's public API which handles encrypted storage transparently.
249+
// Direct SharedPreferences reads break on clerk-android >= 1.0.11 where
250+
// DEVICE_TOKEN is encrypted via StorageCipher.
251+
val deviceToken = Clerk.getDeviceToken()
250252
promise.resolve(deviceToken)
251253
} catch (e: Exception) {
252254
debugLog(TAG, "getClientToken failed: ${e.message}")
@@ -272,6 +274,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
272274
coroutineScope.launch {
273275
try {
274276
Clerk.auth.signOut()
277+
// Client refresh after sign-out is handled by the clerk-android
278+
// SDK (SignOutService.signOut calls Client.getSkippingClientId).
275279
promise.resolve(null)
276280
} catch (e: Exception) {
277281
promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign out failed", e)

packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue
1919
import androidx.compose.ui.Modifier
2020
import androidx.lifecycle.compose.collectAsStateWithLifecycle
2121
import com.clerk.api.Clerk
22+
import com.clerk.api.network.model.client.Client
2223
import com.clerk.ui.userprofile.UserProfileView
2324

2425
/**
@@ -71,7 +72,17 @@ class ClerkUserProfileActivity : ComponentActivity() {
7172
// Detect sign-out: if we had a session and now it's null, user signed out
7273
LaunchedEffect(session) {
7374
if (hadSession && session == null) {
74-
debugLog(TAG, "Sign-out detected - session became null, dismissing activity")
75+
debugLog(TAG, "Sign-out detected - session became null")
76+
// Fetch a brand-new client from the server, skipping the in-memory
77+
// client_id header. Without skipping, the server echoes back the SAME
78+
// client (with the previous user's in-progress signIn still attached),
79+
// and the AuthView re-mounts into the "Get help" fallback because the
80+
// stale signIn's status has no startingFirstFactor.
81+
try {
82+
Client.getSkippingClientId()
83+
} catch (e: Exception) {
84+
Log.w(TAG, "Client.getSkippingClientId() after UserProfile sign-out failed: ${e.message}")
85+
}
7586
finishWithSuccess()
7687
}
7788
// Update hadSession if we get a session (handles edge cases)

packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
2424
import androidx.savedstate.compose.LocalSavedStateRegistryOwner
2525
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
2626
import com.clerk.api.Clerk
27+
import com.clerk.api.network.model.client.Client
2728
import com.clerk.ui.userprofile.UserProfileView
2829
import com.facebook.react.bridge.Arguments
2930
import com.facebook.react.bridge.ReactContext
@@ -77,6 +78,17 @@ class ClerkUserProfileNativeView(context: Context) : FrameLayout(context) {
7778
LaunchedEffect(session) {
7879
if (hadSession && session == null) {
7980
Log.d(TAG, "Sign-out detected")
81+
// Refresh the client from the server to clear any stale in-progress
82+
// signIn/signUp state. Without this, when the AuthView re-mounts after
83+
// sign-out it routes to the "Get help" fallback because the previous
84+
// user's signIn is still in Clerk.client. Clerk.auth.signOut() (called
85+
// internally by UserProfileView) only clears session/user state, not
86+
// the in-progress signIn.
87+
try {
88+
Client.getSkippingClientId()
89+
} catch (e: Exception) {
90+
Log.w(TAG, "Client.getSkippingClientId() after UserProfile sign-out failed: ${e.message}")
91+
}
8092
sendEvent("signedOut", emptyMap())
8193
}
8294
if (session != null) {

packages/expo/ios/ClerkExpoModule.swift

Lines changed: 107 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -219,24 +219,36 @@ class ClerkExpoModule: RCTEventEmitter {
219219
// MARK: - Inline View: ClerkAuthNativeView
220220

221221
public class ClerkAuthNativeView: UIView {
222-
private var hostingController: UIViewController?
223222
private var currentMode: String = "signInOrUp"
224223
private var currentDismissable: Bool = true
225224
private var hasInitialized: Bool = false
225+
private var authEventSent: Bool = false
226+
private var presentedAuthVC: UIViewController?
227+
private var isInvalidated: Bool = false
226228

227229
@objc var onAuthEvent: RCTBubblingEventBlock?
228230

229231
@objc var mode: NSString? {
230232
didSet {
231-
currentMode = (mode as String?) ?? "signInOrUp"
232-
if hasInitialized { updateView() }
233+
let newMode = (mode as String?) ?? "signInOrUp"
234+
guard newMode != currentMode else { return }
235+
currentMode = newMode
236+
if hasInitialized {
237+
dismissAuthModal()
238+
presentAuthModal()
239+
}
233240
}
234241
}
235242

236243
@objc var isDismissable: NSNumber? {
237244
didSet {
238-
currentDismissable = isDismissable?.boolValue ?? true
239-
if hasInitialized { updateView() }
245+
let newDismissable = isDismissable?.boolValue ?? true
246+
guard newDismissable != currentDismissable else { return }
247+
currentDismissable = newDismissable
248+
if hasInitialized {
249+
dismissAuthModal()
250+
presentAuthModal()
251+
}
240252
}
241253
}
242254

@@ -252,65 +264,114 @@ public class ClerkAuthNativeView: UIView {
252264
super.didMoveToWindow()
253265
if window != nil && !hasInitialized {
254266
hasInitialized = true
255-
updateView()
267+
presentAuthModal()
256268
}
257269
}
258270

259-
private func updateView() {
260-
// Remove old hosting controller
261-
hostingController?.view.removeFromSuperview()
262-
hostingController?.removeFromParent()
263-
hostingController = nil
271+
override public func removeFromSuperview() {
272+
isInvalidated = true
273+
dismissAuthModal()
274+
super.removeFromSuperview()
275+
}
276+
277+
// MARK: - Modal Presentation
278+
//
279+
// The AuthView is presented as a real modal rather than embedded inline.
280+
// Embedding a UIHostingController as a child of a React Native view disrupts
281+
// ASWebAuthenticationSession callbacks during OAuth flows (e.g., SSO from the
282+
// forgot-password screen). Modal presentation provides an isolated SwiftUI
283+
// lifecycle that handles all OAuth flows correctly.
264284

285+
private func presentAuthModal() {
265286
guard let factory = clerkViewFactory else { return }
266287

267-
guard let returnedController = factory.createAuthView(
288+
guard let authVC = factory.createAuthViewController(
268289
mode: currentMode,
269290
dismissable: currentDismissable,
270-
onEvent: { [weak self] eventName, data in
271-
// Convert data dict to JSON string for codegen event
272-
let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data()
273-
let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
274-
self?.onAuthEvent?(["type": eventName, "data": jsonString])
275-
276-
// Also emit module-level event so ClerkProvider's useNativeAuthEvents picks it up
277-
if eventName == "signInCompleted" || eventName == "signUpCompleted" {
278-
let sessionId = data["sessionId"] as? String
279-
ClerkExpoModule.emitAuthStateChange(type: "signedIn", sessionId: sessionId)
291+
completion: { [weak self] result in
292+
guard let self = self, !self.authEventSent else { return }
293+
switch result {
294+
case .success(let data):
295+
if let _ = data["cancelled"] {
296+
// User dismissed — don't send auth event
297+
return
298+
}
299+
self.authEventSent = true
300+
self.sendAuthEvent(type: "signInCompleted", data: data)
301+
case .failure:
302+
break
280303
}
281304
}
282305
) else { return }
283306

284-
// Attach the returned UIHostingController as a child to preserve SwiftUI lifecycle
285-
if let parentVC = findViewController() {
286-
parentVC.addChild(returnedController)
287-
returnedController.view.frame = bounds
288-
returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
289-
addSubview(returnedController.view)
290-
returnedController.didMove(toParent: parentVC)
291-
hostingController = returnedController
292-
} else {
293-
returnedController.view.frame = bounds
294-
returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
295-
addSubview(returnedController.view)
296-
hostingController = returnedController
297-
}
307+
authVC.modalPresentationStyle = .fullScreen
308+
// Try to present immediately. Only wait if a previous modal is dismissing.
309+
presentWhenReady(authVC, attempts: 0)
298310
}
299311

300-
private func findViewController() -> UIViewController? {
301-
var responder: UIResponder? = self
302-
while let nextResponder = responder?.next {
303-
if let vc = nextResponder as? UIViewController {
304-
return vc
312+
private func dismissAuthModal() {
313+
presentedAuthVC?.dismiss(animated: false)
314+
presentedAuthVC = nil
315+
}
316+
317+
/// Presents the auth view controller as soon as it's safe to do so.
318+
/// On initial mount this presents synchronously (no delay, no white flash).
319+
/// If a previous modal is still dismissing, waits for its transition coordinator
320+
/// to finish — no fixed delays.
321+
private func presentWhenReady(_ authVC: UIViewController, attempts: Int) {
322+
guard !isInvalidated, presentedAuthVC == nil, attempts < 30 else { return }
323+
guard let rootVC = Self.topViewController() else {
324+
DispatchQueue.main.async { [weak self] in
325+
self?.presentWhenReady(authVC, attempts: attempts + 1)
305326
}
306-
responder = nextResponder
327+
return
307328
}
308-
return nil
329+
330+
// If a previous modal is animating dismissal, wait for it via the
331+
// transition coordinator instead of a fixed delay.
332+
if let coordinator = rootVC.transitionCoordinator {
333+
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
334+
self?.presentWhenReady(authVC, attempts: attempts + 1)
335+
}
336+
return
337+
}
338+
339+
// If there's still a presented VC (no coordinator yet), wait one frame.
340+
if rootVC.presentedViewController != nil {
341+
DispatchQueue.main.async { [weak self] in
342+
self?.presentWhenReady(authVC, attempts: attempts + 1)
343+
}
344+
return
345+
}
346+
347+
rootVC.present(authVC, animated: false)
348+
presentedAuthVC = authVC
309349
}
310350

311-
override public func layoutSubviews() {
312-
super.layoutSubviews()
313-
hostingController?.view.frame = bounds
351+
private static func topViewController() -> UIViewController? {
352+
guard let scene = UIApplication.shared.connectedScenes
353+
.compactMap({ $0 as? UIWindowScene })
354+
.first(where: { $0.activationState == .foregroundActive }),
355+
let rootVC = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController
356+
else { return nil }
357+
358+
var top = rootVC
359+
while let presented = top.presentedViewController {
360+
top = presented
361+
}
362+
return top
363+
}
364+
365+
private func sendAuthEvent(type: String, data: [String: Any]) {
366+
let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data()
367+
let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
368+
onAuthEvent?(["type": type, "data": jsonString])
369+
370+
// Also emit module-level event so ClerkProvider's useNativeAuthEvents picks it up
371+
if type == "signInCompleted" || type == "signUpCompleted" {
372+
let sessionId = data["sessionId"] as? String
373+
ClerkExpoModule.emitAuthStateChange(type: "signedIn", sessionId: sessionId)
374+
}
314375
}
315376
}
316377

packages/expo/ios/ClerkViewFactory.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,12 @@ class ClerkAuthWrapperViewController: UIHostingController<ClerkAuthWrapperView>
348348
override func viewDidDisappear(_ animated: Bool) {
349349
super.viewDidDisappear(animated)
350350
if isBeingDismissed {
351-
completeOnce(.success(["cancelled": true]))
351+
// Check if auth completed (session exists) vs user cancelled
352+
if let session = Clerk.shared.session, session.id != initialSessionId {
353+
completeOnce(.success(["sessionId": session.id, "type": "signIn"]))
354+
} else {
355+
completeOnce(.success(["cancelled": true]))
356+
}
352357
}
353358
}
354359

0 commit comments

Comments
 (0)