From 04c9d180765af07b1a6b88171d2109f825e518f2 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Wed, 27 May 2026 18:28:36 -0400 Subject: [PATCH 01/14] fix full screen uiviewcontroller --- packages/expo/ios/ClerkExpoModule.swift | 163 ++++++++------------- packages/expo/src/native/AuthView.tsx | 12 +- packages/expo/src/native/AuthView.types.ts | 9 +- 3 files changed, 79 insertions(+), 105 deletions(-) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index efd1e142445..405ed69908f 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -1,7 +1,7 @@ // ClerkExpoModule - Native module for Clerk integration -// This module provides the configure function and view presentation methods. -// Views are presented as modal view controllers (not embedded views) -// because the Clerk SDK (SPM) isn't accessible from CocoaPods. +// This module provides the configure function, session sync, and native view bridges. +// SwiftUI Clerk views are created by the app target through ClerkViewFactory because +// the Clerk SDK (SPM) isn't accessible from the CocoaPods-backed React Native pod. import UIKit import React @@ -11,7 +11,7 @@ public var clerkViewFactory: ClerkViewFactoryProtocol? // Protocol that the app target implements to provide Clerk views public protocol ClerkViewFactoryProtocol { - // Modal presentation (existing) + // Modal presentation helpers. func createAuthViewController(mode: String, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) -> UIViewController? func createUserProfileViewController(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) -> UIViewController? @@ -219,12 +219,12 @@ 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 currentDismissable: Bool = false private var hasInitialized: Bool = false - private var authEventSent: Bool = false - private var presentedAuthVC: UIViewController? - private var isInvalidated: Bool = false + private var didCompleteAuthentication: Bool = false + private var dismissalEventSent: Bool = false @objc var onAuthEvent: RCTBubblingEventBlock? @@ -233,22 +233,16 @@ public class ClerkAuthNativeView: UIView { let newMode = (mode as String?) ?? "signInOrUp" guard newMode != currentMode else { return } currentMode = newMode - if hasInitialized { - dismissAuthModal() - presentAuthModal() - } + if hasInitialized { updateView() } } } @objc var isDismissable: NSNumber? { didSet { - let newDismissable = isDismissable?.boolValue ?? true + let newDismissable = isDismissable?.boolValue ?? false guard newDismissable != currentDismissable else { return } currentDismissable = newDismissable - if hasInitialized { - dismissAuthModal() - presentAuthModal() - } + if hasInitialized { updateView() } } } @@ -264,114 +258,83 @@ public class ClerkAuthNativeView: UIView { super.didMoveToWindow() if window != nil && !hasInitialized { hasInitialized = true - presentAuthModal() + updateView() + } else if window == nil && hasInitialized && currentDismissable && !didCompleteAuthentication && !dismissalEventSent { + dismissalEventSent = true + sendAuthEvent(type: "dismissed", data: [:]) } } - override public func removeFromSuperview() { - isInvalidated = true - dismissAuthModal() - super.removeFromSuperview() + 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]) } - // 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 updateView() { + detachHostingController() - private func presentAuthModal() { guard let factory = clerkViewFactory else { return } - guard let authVC = factory.createAuthViewController( + guard let returnedController = factory.createAuthView( mode: currentMode, dismissable: currentDismissable, - 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 + onEvent: { [weak self] eventName, data in + let didCompleteAuthentication = eventName == "signInCompleted" || eventName == "signUpCompleted" + + if didCompleteAuthentication { + self?.didCompleteAuthentication = true + } + + self?.sendAuthEvent(type: eventName, data: data) + + if didCompleteAuthentication { + let sessionId = data["sessionId"] as? String + ClerkExpoModule.emitAuthStateChange(type: "signedIn", sessionId: sessionId) } } ) else { return } - authVC.modalPresentationStyle = .fullScreen - // Try to present immediately. Only wait if a previous modal is dismissing. - presentWhenReady(authVC, attempts: 0) + attachHostingController(returnedController) } - private func dismissAuthModal() { - presentedAuthVC?.dismiss(animated: false) - presentedAuthVC = nil - } + private func attachHostingController(_ controller: UIViewController) { + controller.view.frame = bounds + controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - /// 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) - } - return - } - - // If a previous modal is animating dismissal, wait for it via the - // transition coordinator instead of a fixed delay. - if let coordinator = rootVC.transitionCoordinator { - coordinator.animate(alongsideTransition: nil) { [weak self] _ in - self?.presentWhenReady(authVC, attempts: attempts + 1) - } - return - } - - // If there's still a presented VC (no coordinator yet), wait one frame. - if rootVC.presentedViewController != nil { - DispatchQueue.main.async { [weak self] in - self?.presentWhenReady(authVC, attempts: attempts + 1) - } - return + if let parentVC = findViewController() { + parentVC.addChild(controller) + addSubview(controller.view) + controller.didMove(toParent: parentVC) + } else { + addSubview(controller.view) } - rootVC.present(authVC, animated: false) - presentedAuthVC = authVC + hostingController = controller } - 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 } + private func detachHostingController() { + guard let controller = hostingController else { return } + controller.willMove(toParent: nil) + controller.view.removeFromSuperview() + controller.removeFromParent() + hostingController = nil + } - var top = rootVC - while let presented = top.presentedViewController { - top = presented + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let nextResponder = responder?.next { + if let vc = nextResponder as? UIViewController { + return vc + } + responder = nextResponder } - return top + return nil } - 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) - } + override public func layoutSubviews() { + super.layoutSubviews() + hostingController?.view.frame = bounds } } diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx index a101a7cd0ad..1216c99f7ca 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo/src/native/AuthView.tsx @@ -63,7 +63,8 @@ export async function syncNativeSession(sessionId: string): Promise { * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android * * After authentication completes, the session is automatically synced with the JS SDK. - * Use `useAuth()`, `useUser()`, or `useSession()` in a `useEffect` to react to state changes. + * Use `useAuth()`, `useUser()`, or `useSession()` to react to authentication + * state changes. * * @example * ```tsx @@ -83,7 +84,7 @@ export async function syncNativeSession(sessionId: string): Promise { * * @see {@link https://clerk.com/docs/components/authentication/sign-in} Clerk Sign-In Documentation */ -export function AuthView({ mode = 'signInOrUp', isDismissable = false }: AuthViewProps) { +export function AuthView({ mode = 'signInOrUp', isDismissable = false, onDismiss }: AuthViewProps) { const authCompletedRef = useRef(false); const syncSession = useCallback(async (sessionId: string) => { @@ -116,6 +117,11 @@ export function AuthView({ mode = 'signInOrUp', isDismissable = false }: AuthVie } const data: Record = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; + if (type === 'dismissed') { + onDismiss?.(); + return; + } + if (type === 'signInCompleted' || type === 'signUpCompleted') { const sessionId = data?.sessionId; if (sessionId) { @@ -125,7 +131,7 @@ export function AuthView({ mode = 'signInOrUp', isDismissable = false }: AuthVie } } }, - [syncSession], + [onDismiss, syncSession], ); if (!isNativeSupported || !NativeClerkAuthView) { diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts index 2f316488827..e795c44ed02 100644 --- a/packages/expo/src/native/AuthView.types.ts +++ b/packages/expo/src/native/AuthView.types.ts @@ -11,8 +11,8 @@ export type AuthViewMode = 'signIn' | 'signUp' | 'signInOrUp'; * Props for the AuthView component. * * AuthView renders a native authentication UI inline (fills parent container). - * Use `useAuth()`, `useUser()`, or `useSession()` in a `useEffect` to react - * to authentication state changes. + * Use `useAuth()`, `useUser()`, or `useSession()` to react to authentication + * state changes. */ export interface AuthViewProps { /** @@ -37,4 +37,9 @@ export interface AuthViewProps { * @default false */ isDismissable?: boolean; + + /** + * Called when the user dismisses the native authentication view. + */ + onDismiss?: () => void; } From c78c560312b2e2391280e984d50b12a291d87c22 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 28 May 2026 10:34:24 -0400 Subject: [PATCH 02/14] fix(expo): add changeset for auth view presentation --- .changeset/fullscreen-auth-view.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fullscreen-auth-view.md diff --git a/.changeset/fullscreen-auth-view.md b/.changeset/fullscreen-auth-view.md new file mode 100644 index 00000000000..a71a972e04d --- /dev/null +++ b/.changeset/fullscreen-auth-view.md @@ -0,0 +1,5 @@ +--- +"@clerk/expo": patch +--- + +Fix native iOS auth view presentation for full screen UIViewController flows. From 662aba58be84b380027b595fbb9fe4bdc9a95dad Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 28 May 2026 16:05:56 -0400 Subject: [PATCH 03/14] Remove unused auth presentation bridge from Expo native module --- .changeset/remove-expo-present-auth.md | 5 + .../expo/android/src/main/AndroidManifest.xml | 7 - .../expo/modules/clerk/ClerkAuthActivity.kt | 306 ------------------ .../expo/modules/clerk/ClerkExpoModule.kt | 79 ----- .../expo/modules/clerk/ClerkViewFactory.kt | 8 - .../clerk/ClerkViewFactoryInterface.kt | 7 - packages/expo/ios/ClerkExpoModule.m | 4 - packages/expo/ios/ClerkExpoModule.swift | 36 --- packages/expo/ios/ClerkViewFactory.swift | 108 ------- packages/expo/src/specs/NativeClerkModule.ts | 1 - 10 files changed, 5 insertions(+), 556 deletions(-) create mode 100644 .changeset/remove-expo-present-auth.md delete mode 100644 packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt diff --git a/.changeset/remove-expo-present-auth.md b/.changeset/remove-expo-present-auth.md new file mode 100644 index 00000000000..be1af4475e2 --- /dev/null +++ b/.changeset/remove-expo-present-auth.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Remove the unused native `presentAuth` bridge so prebuilt `AuthView` is only rendered as content owned by the React Native view hierarchy. diff --git a/packages/expo/android/src/main/AndroidManifest.xml b/packages/expo/android/src/main/AndroidManifest.xml index 4683222f409..b60cb278cf8 100644 --- a/packages/expo/android/src/main/AndroidManifest.xml +++ b/packages/expo/android/src/main/AndroidManifest.xml @@ -1,12 +1,5 @@ - - - (null) } - // Track if we've already triggered prepareVerification for this signUp - var preparedSignUpId by remember { mutableStateOf(null) } - - // Track if we've already triggered prepareSecondFactor for this signIn - var preparedSecondFactorSignInId by remember { mutableStateOf(null) } - - // Monitor signUp state changes and manually trigger prepareVerification - LaunchedEffect(isClientReady) { - if (isClientReady) { - while (true) { - delay(POLL_INTERVAL_MS) - val client = Clerk.client - val signUp = client?.signUp - - if (signUp != null && signUp.id != lastSignUpId) { - lastSignUpId = signUp.id - debugLog(TAG, "New signUp detected, status: ${signUp.status}") - } - - // Manually trigger prepareVerification if needed - // This is a workaround for clerk-android-ui not calling prepareVerification - if (signUp != null && - signUp.id != preparedSignUpId && - signUp.emailAddress != null && - signUp.status == SignUp.Status.MISSING_REQUIREMENTS) { - - val emailVerification = signUp.verifications?.get("email_address") - // Only prepare if email is unverified - if (emailVerification?.status?.name == "UNVERIFIED") { - preparedSignUpId = signUp.id - - try { - val result = signUp.prepareVerification( - SignUp.PrepareVerificationParams.Strategy.EmailCode() - ) - result - .onSuccess { - debugLog(TAG, "prepareVerification succeeded") - } - .onFailure { error -> - Log.e(TAG, "prepareVerification failed: ${error.errorMessage}") - } - } catch (e: Exception) { - Log.e(TAG, "prepareVerification exception: ${e.message}") - } - } - } - - // Manually trigger prepareSecondFactor for MFA if needed - // This is a workaround for clerk-android-ui not calling prepareSecondFactor - val signIn = client?.signIn - if (signIn != null && - signIn.id != preparedSecondFactorSignInId && - signIn.status == SignIn.Status.NEEDS_SECOND_FACTOR) { - - preparedSecondFactorSignInId = signIn.id - - try { - val result = signIn.prepareSecondFactor() - result - .onSuccess { updatedSignIn -> - debugLog(TAG, "prepareSecondFactor succeeded, status: ${updatedSignIn.status}") - } - .onFailure { error -> - Log.e(TAG, "prepareSecondFactor failed: ${error.errorMessage}") - // Reset so we can retry - preparedSecondFactorSignInId = null - } - } catch (e: Exception) { - Log.e(TAG, "prepareSecondFactor exception: ${e.message}") - // Reset so we can retry - preparedSecondFactorSignInId = null - } - } - - // Check if auth completed - finish activity immediately - val currentSession = Clerk.session - if (currentSession != null && authCompleteGuard.compareAndSet(false, true)) { - isAuthComplete = true - - val resultIntent = Intent().apply { - putExtra("sessionId", currentSession.id) - putExtra("userId", currentSession.user?.id ?: Clerk.user?.id) - } - setResult(Activity.RESULT_OK, resultIntent) - finish() - break - } - } - } - } - - // Backup: Also listen for session via Flow (in case polling misses it) - LaunchedEffect(session) { - if (session != null && initialSession == null && authCompleteGuard.compareAndSet(false, true)) { - // Mark auth as complete FIRST to hide AuthView - // This prevents the "NavDisplay backstack cannot be empty" crash - isAuthComplete = true - - // Small delay to let the UI update before finishing - delay(100) - - // Auth completed - return session info - val resultIntent = Intent().apply { - putExtra("sessionId", session?.id) - putExtra("userId", session?.user?.id ?: user?.id) - } - setResult(Activity.RESULT_OK, resultIntent) - finish() - } - } - - // Handle back press - if (dismissable) { - BackHandler { - setResult(Activity.RESULT_CANCELED) - finish() - } - } else { - // Block back press when not dismissable - BackHandler { /* Do nothing */ } - } - - // Render Clerk's AuthView in a Material3 surface - MaterialTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - when { - isAuthComplete -> { - // Auth completed - show success indicator while finishing - // This prevents AuthView from crashing with empty navigation backstack - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator( - modifier = Modifier.size(48.dp) - ) - Text( - text = "Signed in!", - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - isClientReady -> { - // Client is ready, show AuthView - AuthView( - modifier = Modifier.fillMaxSize(), - clerkTheme = Clerk.customTheme - ) - } - else -> { - // Show loading while waiting for client to sync - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator( - modifier = Modifier.size(48.dp) - ) - Text( - text = "Loading...", - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - } - } - } - } - } -} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index 7c822cfda40..6871453873a 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -38,20 +38,17 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : ActivityEventListener { companion object { - const val CLERK_AUTH_REQUEST_CODE = 9001 const val CLERK_PROFILE_REQUEST_CODE = 9002 // Intent extras const val EXTRA_DISMISSABLE = "dismissable" const val EXTRA_PUBLISHABLE_KEY = "publishableKey" - const val EXTRA_MODE = "mode" // Result extras const val RESULT_SESSION_ID = "sessionId" const val RESULT_CANCELLED = "cancelled" // Pending promises for activity results - private var pendingAuthPromise: Promise? = null private var pendingProfilePromise: Promise? = null // Store publishable key for passing to activities @@ -161,40 +158,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } } - // MARK: - presentAuth - - @ReactMethod - override fun presentAuth(options: ReadableMap, promise: Promise) { - val activity = getCurrentActivity() ?: run { - promise.reject("E_ACTIVITY_UNAVAILABLE", "No activity available to present Clerk UI.") - return - } - - if (!Clerk.isInitialized.value) { - promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") - return - } - - // Check if user is already signed in - if (Clerk.session != null) { - promise.reject("already_signed_in", "User is already signed in") - return - } - - pendingAuthPromise?.reject("E_SUPERSEDED", "Auth presentation was superseded") - pendingAuthPromise = promise - - val mode = if (options.hasKey("mode")) options.getString("mode") ?: "signInOrUp" else "signInOrUp" - val dismissable = if (options.hasKey("dismissable")) options.getBoolean("dismissable") else true - - val intent = Intent(activity, ClerkAuthActivity::class.java).apply { - putExtra(EXTRA_MODE, mode) - putExtra(EXTRA_DISMISSABLE, dismissable) - } - - activity.startActivityForResult(intent, CLERK_AUTH_REQUEST_CODE) - } - // MARK: - presentUserProfile @ReactMethod @@ -310,7 +273,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) { when (requestCode) { - CLERK_AUTH_REQUEST_CODE -> handleAuthResult(resultCode, data) CLERK_PROFILE_REQUEST_CODE -> handleProfileResult(resultCode, data) } } @@ -319,47 +281,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : // Not used } - private fun handleAuthResult(resultCode: Int, data: Intent?) { - val promise = pendingAuthPromise ?: return - pendingAuthPromise = null - - if (resultCode == Activity.RESULT_OK) { - val session = Clerk.session - val user = Clerk.user - - val result = WritableNativeMap() - - // Top-level sessionId for JS SDK compatibility (matches iOS response format) - result.putString("sessionId", session?.id) - - session?.let { - val sessionMap = WritableNativeMap() - sessionMap.putString("id", it.id) - sessionMap.putString("status", it.status.name) - sessionMap.putString("userId", it.user?.id) - result.putMap("session", sessionMap) - } - - user?.let { - val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId } - - val userMap = WritableNativeMap() - userMap.putString("id", it.id) - userMap.putString("firstName", it.firstName) - userMap.putString("lastName", it.lastName) - userMap.putString("imageUrl", it.imageUrl) - userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress) - result.putMap("user", userMap) - } - - promise.resolve(result) - } else { - val result = WritableNativeMap() - result.putBoolean("cancelled", true) - promise.resolve(result) - } - } - private fun handleProfileResult(resultCode: Int, data: Intent?) { val promise = pendingProfilePromise ?: return pendingProfilePromise = null diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt index e77ad21ddf0..c02e519d874 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt @@ -35,14 +35,6 @@ class ClerkViewFactory : ClerkViewFactoryInterface { } } - override fun createAuthIntent(context: Context, mode: String, dismissable: Boolean): Intent { - return Intent(context, ClerkAuthActivity::class.java).apply { - putExtra(ClerkExpoModule.EXTRA_MODE, mode) - putExtra(ClerkExpoModule.EXTRA_DISMISSABLE, dismissable) - storedPublishableKey?.let { putExtra(ClerkExpoModule.EXTRA_PUBLISHABLE_KEY, it) } - } - } - override fun createUserProfileIntent(context: Context, dismissable: Boolean): Intent { return Intent(context, ClerkUserProfileActivity::class.java).apply { putExtra(ClerkExpoModule.EXTRA_DISMISSABLE, dismissable) diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt index 7b82bd1ec20..665d0188890 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt @@ -13,13 +13,6 @@ interface ClerkViewFactoryInterface { */ suspend fun configure(context: Context, publishableKey: String) - /** - * Create an Intent to launch the authentication activity. - * @param mode The auth mode: "signIn", "signUp", or "signInOrUp" - * @param dismissable Whether the user can dismiss the modal - */ - fun createAuthIntent(context: Context, mode: String, dismissable: Boolean): Intent - /** * Create an Intent to launch the user profile activity. * @param dismissable Whether the user can dismiss the modal diff --git a/packages/expo/ios/ClerkExpoModule.m b/packages/expo/ios/ClerkExpoModule.m index febfe003c61..6ca533d1593 100644 --- a/packages/expo/ios/ClerkExpoModule.m +++ b/packages/expo/ios/ClerkExpoModule.m @@ -8,10 +8,6 @@ @interface RCT_EXTERN_MODULE(ClerkExpo, RCTEventEmitter) resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(presentAuth:(NSDictionary *)options - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) - RCT_EXTERN_METHOD(presentUserProfile:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index 405ed69908f..3d85c175e81 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -11,8 +11,6 @@ public var clerkViewFactory: ClerkViewFactoryProtocol? // Protocol that the app target implements to provide Clerk views public protocol ClerkViewFactoryProtocol { - // Modal presentation helpers. - func createAuthViewController(mode: String, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) -> UIViewController? func createUserProfileViewController(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) -> UIViewController? // Inline rendering — returns UIViewController to preserve SwiftUI lifecycle @@ -102,40 +100,6 @@ class ClerkExpoModule: RCTEventEmitter { } } - // MARK: - presentAuth - - @objc func presentAuth(_ options: NSDictionary, - resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { - guard let factory = clerkViewFactory else { - reject("E_NOT_INITIALIZED", "Clerk not initialized", nil) - return - } - - let mode = options["mode"] as? String ?? "signInOrUp" - let dismissable = options["dismissable"] as? Bool ?? true - - DispatchQueue.main.async { - guard let vc = factory.createAuthViewController(mode: mode, dismissable: dismissable, completion: { result in - switch result { - case .success(let data): - resolve(data) - case .failure(let error): - reject("E_AUTH_FAILED", error.localizedDescription, error) - } - }) else { - reject("E_CREATE_FAILED", "Could not create auth view controller", nil) - return - } - - if let rootVC = Self.topViewController() { - rootVC.present(vc, animated: true) - } else { - reject("E_NO_ROOT_VC", "No root view controller available to present auth", nil) - } - } - } - // MARK: - presentUserProfile @objc func presentUserProfile(_ options: NSDictionary, diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index 7e1925f41be..e67c44b8efa 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -149,21 +149,6 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { Self.readNativeDeviceToken() } - public func createAuthViewController( - mode: String, - dismissable: Bool, - completion: @escaping (Result<[String: Any], Error>) -> Void - ) -> UIViewController? { - let wrapper = ClerkAuthWrapperViewController( - mode: Self.authMode(from: mode), - dismissable: dismissable, - lightTheme: lightTheme, - darkTheme: darkTheme, - completion: completion - ) - return wrapper - } - public func createUserProfileViewController( dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void @@ -420,99 +405,6 @@ private struct ExpoKeychain { } } -// MARK: - Auth View Controller Wrapper - -class ClerkAuthWrapperViewController: UIHostingController { - private let completion: (Result<[String: Any], Error>) -> Void - private var authEventTask: Task? - private var completionCalled = false - - init(mode: AuthView.Mode, dismissable: Bool, lightTheme: ClerkTheme?, darkTheme: ClerkTheme?, completion: @escaping (Result<[String: Any], Error>) -> Void) { - self.completion = completion - let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable, lightTheme: lightTheme, darkTheme: darkTheme) - super.init(rootView: view) - self.modalPresentationStyle = .fullScreen - subscribeToAuthEvents() - } - - @MainActor required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - authEventTask?.cancel() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - if isBeingDismissed { - // 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])) - } - } - } - - private func completeOnce(_ result: Result<[String: Any], Error>) { - guard !completionCalled else { return } - completionCalled = true - completion(result) - } - - private var initialSessionId: String? = Clerk.shared.session?.id - - private func subscribeToAuthEvents() { - authEventTask = Task { @MainActor [weak self] in - for await event in Clerk.shared.auth.events { - guard let self = self, !self.completionCalled else { return } - switch event { - case .signInCompleted(let signIn): - let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id - if let sessionId, sessionId != self.initialSessionId { - self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"])) - self.dismiss(animated: true) - } - case .signUpCompleted(let signUp): - let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id - if let sessionId, sessionId != self.initialSessionId { - self.completeOnce(.success(["sessionId": sessionId, "type": "signUp"])) - self.dismiss(animated: true) - } - case .sessionChanged(_, let newSession): - if let sessionId = newSession?.id, sessionId != self.initialSessionId { - self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"])) - self.dismiss(animated: true) - } - default: - break - } - } - } - } -} - -struct ClerkAuthWrapperView: View { - let mode: AuthView.Mode - let dismissable: Bool - let lightTheme: ClerkTheme? - let darkTheme: ClerkTheme? - - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - let view = AuthView(mode: mode, isDismissable: dismissable) - .environment(Clerk.shared) - let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme - if let theme { - view.environment(\.clerkTheme, theme) - } else { - view - } - } -} - // MARK: - Profile View Controller Wrapper class ClerkProfileWrapperViewController: UIHostingController { diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index 1c38d2c1f92..527a40e0882 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -4,7 +4,6 @@ import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypesName export interface Spec extends TurboModule { configure(publishableKey: string, bearerToken: string | null): Promise; - presentAuth(options: UnsafeObject): Promise; presentUserProfile(options: UnsafeObject): Promise; getSession(): Promise; getClientToken(): Promise; From ba282051519bcb8c1a4f33959c17830ff70b8477 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 28 May 2026 16:10:29 -0400 Subject: [PATCH 04/14] Remove unused Expo prebuilt bridge code --- .changeset/remove-expo-present-auth.md | 2 +- .../expo/modules/clerk/ClerkExpoModule.kt | 4 - .../expo/modules/clerk/ClerkViewFactory.kt | 94 ----------- .../clerk/ClerkViewFactoryInterface.kt | 45 ------ packages/expo/src/native/InlineAuthView.tsx | 151 ------------------ .../expo/src/native/InlineUserProfileView.tsx | 109 ------------- 6 files changed, 1 insertion(+), 404 deletions(-) delete mode 100644 packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt delete mode 100644 packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt delete mode 100644 packages/expo/src/native/InlineAuthView.tsx delete mode 100644 packages/expo/src/native/InlineUserProfileView.tsx diff --git a/.changeset/remove-expo-present-auth.md b/.changeset/remove-expo-present-auth.md index be1af4475e2..35781bbdd0f 100644 --- a/.changeset/remove-expo-present-auth.md +++ b/.changeset/remove-expo-present-auth.md @@ -2,4 +2,4 @@ '@clerk/expo': patch --- -Remove the unused native `presentAuth` bridge so prebuilt `AuthView` is only rendered as content owned by the React Native view hierarchy. +Remove unused Expo prebuilt-view bridge code: the native `presentAuth` bridge, the Android auth activity/factory path, and stale `Inline*` source files now superseded by `AuthView` and `UserProfileView`. diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index 6871453873a..d13728f2ec6 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -92,10 +92,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : // cold start before React's host-resume sync — AuthView and // UserProfile also call attachActivity() on mount as a backstop. getCurrentActivity()?.let { Clerk.attachActivity(it) } - // Theme loading is centralized here. ClerkViewFactory.configure() - // and ClerkUserProfileActivity.onCreate() only call Clerk.initialize() - // when Clerk is not yet initialized, so by the time they run - // ClerkExpoModule has already set the custom theme. // Must be set AFTER Clerk.initialize() because initialize() // resets customTheme to its `theme` parameter (default null). loadThemeFromAssets() diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt deleted file mode 100644 index c02e519d874..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt +++ /dev/null @@ -1,94 +0,0 @@ -package expo.modules.clerk - -import android.content.Context -import android.content.Intent -import com.clerk.api.Clerk -import com.clerk.api.network.serialization.ClerkResult -import kotlinx.coroutines.flow.first - -/** - * Implementation of ClerkViewFactoryInterface. - * Provides Clerk SDK operations and creates intents for auth/profile activities. - */ -class ClerkViewFactory : ClerkViewFactoryInterface { - - // Store the publishable key for later use - private var storedPublishableKey: String? = null - private var storedContext: Context? = null - - override suspend fun configure(context: Context, publishableKey: String) { - println("[ClerkViewFactory] Configuring Clerk with publishable key: ${publishableKey.take(20)}...") - - // Store for later use - storedPublishableKey = publishableKey - storedContext = context.applicationContext - - // Initialize Clerk if not already initialized - if (!Clerk.isInitialized.value) { - Clerk.initialize(context.applicationContext, publishableKey) - - // Wait for initialization to complete - Clerk.isInitialized.first { it } - println("[ClerkViewFactory] Clerk initialized successfully") - } else { - println("[ClerkViewFactory] Clerk already initialized") - } - } - - override fun createUserProfileIntent(context: Context, dismissable: Boolean): Intent { - return Intent(context, ClerkUserProfileActivity::class.java).apply { - putExtra(ClerkExpoModule.EXTRA_DISMISSABLE, dismissable) - storedPublishableKey?.let { putExtra(ClerkExpoModule.EXTRA_PUBLISHABLE_KEY, it) } - } - } - - override suspend fun getSession(): Map? { - val session = Clerk.session ?: return null - val user = Clerk.user ?: return null - - return mapOf( - "sessionId" to session.id, - "userId" to user.id, - "user" to mapOf( - "id" to user.id, - "firstName" to user.firstName, - "lastName" to user.lastName, - "fullName" to "${user.firstName ?: ""} ${user.lastName ?: ""}".trim().ifEmpty { null }, - "username" to user.username, - "imageUrl" to user.imageUrl, - "primaryEmailAddress" to user.primaryEmailAddress?.emailAddress, - "primaryPhoneNumber" to user.primaryPhoneNumber?.phoneNumber, - "createdAt" to user.createdAt, - "updatedAt" to user.updatedAt, - ) - ) - } - - override suspend fun signOut() { - val result = Clerk.auth.signOut() - when (result) { - is ClerkResult.Success -> { - println("[ClerkViewFactory] Sign out successful") - } - is ClerkResult.Failure -> { - println("[ClerkViewFactory] Sign out failed: ${result.error}") - throw Exception("Sign out failed: ${result.error}") - } - } - } - - override fun isInitialized(): Boolean { - return Clerk.isInitialized.value - } - - companion object { - /** - * Initialize the ClerkViewFactory and register it globally. - * Call this from your Application.onCreate() or MainActivity.onCreate() - */ - fun initialize() { - ClerkViewFactoryRegistry.factory = ClerkViewFactory() - println("[ClerkViewFactory] Factory registered") - } - } -} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt deleted file mode 100644 index 665d0188890..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt +++ /dev/null @@ -1,45 +0,0 @@ -package expo.modules.clerk - -import android.content.Context -import android.content.Intent - -/** - * Interface for providing Clerk views and SDK operations. - * This mirrors the iOS ClerkViewFactoryProtocol pattern. - */ -interface ClerkViewFactoryInterface { - /** - * Configure the Clerk SDK with the publishable key. - */ - suspend fun configure(context: Context, publishableKey: String) - - /** - * Create an Intent to launch the user profile activity. - * @param dismissable Whether the user can dismiss the modal - */ - fun createUserProfileIntent(context: Context, dismissable: Boolean): Intent - - /** - * Get the current session data as a Map for JS. - * Returns null if no session is active. - */ - suspend fun getSession(): Map? - - /** - * Sign out the current user. - */ - suspend fun signOut() - - /** - * Check if the SDK is initialized. - */ - fun isInitialized(): Boolean -} - -/** - * Global registry for the Clerk view factory. - * Set by the app target at startup (similar to iOS pattern). - */ -object ClerkViewFactoryRegistry { - var factory: ClerkViewFactoryInterface? = null -} diff --git a/packages/expo/src/native/InlineAuthView.tsx b/packages/expo/src/native/InlineAuthView.tsx deleted file mode 100644 index e4c2b682871..00000000000 --- a/packages/expo/src/native/InlineAuthView.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { ClerkRuntimeError } from '@clerk/shared/error'; -import { useCallback, useRef } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; - -import { CLERK_CLIENT_JWT_KEY } from '../constants'; -import { getClerkInstance } from '../provider/singleton'; -import NativeClerkAuthView from '../specs/NativeClerkAuthView'; -import { tokenCache } from '../token-cache'; -import { ClerkExpoModule, isNativeSupported } from '../utils/native-module'; -import type { AuthViewMode } from './AuthView.types'; - -export interface InlineAuthViewProps { - /** - * Authentication mode that determines which flows are available. - * @default 'signInOrUp' - */ - mode?: AuthViewMode; - - /** - * Whether the authentication view can be dismissed by the user. - * @default false - */ - isDismissable?: boolean; -} - -/** - * An inline native authentication component that renders in-place. - * - * `InlineAuthView` renders directly within your React Native view hierarchy, - * allowing you to embed the native authentication UI anywhere in your layout. - * - * After authentication completes, the session is automatically synced with the JS SDK. - * Use `useAuth()`, `useUser()`, or `useSession()` in a `useEffect` to react to state changes. - * - * @example - * ```tsx - * import { InlineAuthView } from '@clerk/expo/native'; - * import { useAuth } from '@clerk/expo'; - * - * export default function SignInScreen() { - * const { isSignedIn } = useAuth(); - * - * useEffect(() => { - * if (isSignedIn) router.replace('/home'); - * }, [isSignedIn]); - * - * return ( - * - * Welcome - * - * - * ); - * } - * ``` - */ -export function InlineAuthView({ mode = 'signInOrUp', isDismissable = false }: InlineAuthViewProps) { - const authCompletedRef = useRef(false); - - const syncSession = useCallback(async (sessionId: string) => { - if (authCompletedRef.current) { - return; - } - - try { - if (ClerkExpoModule?.getClientToken) { - const nativeClientToken = await ClerkExpoModule.getClientToken(); - if (nativeClientToken) { - await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); - } - } - - const clerkInstance = getClerkInstance(); - if (!clerkInstance) { - throw new ClerkRuntimeError( - 'Clerk instance is not available. Ensure is mounted before using .', - { code: 'expo_inline_auth_view_clerk_instance_not_available' }, - ); - } - - const clerkRecord = clerkInstance as unknown as Record; - if (typeof clerkRecord.__internal_reloadInitialResources === 'function') { - await (clerkRecord.__internal_reloadInitialResources as () => Promise)(); - } - - if (typeof clerkInstance.setActive === 'function') { - await clerkInstance.setActive({ session: sessionId }); - } - - authCompletedRef.current = true; - } catch (err) { - if (__DEV__) { - console.error('[InlineAuthView] Failed to sync session:', err); - } - } - }, []); - - const handleAuthEvent = useCallback( - async (event: { nativeEvent: { type: string; data: string } }) => { - const { type, data: rawData } = event.nativeEvent; - if (__DEV__) { - console.log('[InlineAuthView] onAuthEvent:', type, rawData); - } - const data: Record = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; - - if (type === 'signInCompleted' || type === 'signUpCompleted') { - const sessionId = data?.sessionId; - if (sessionId) { - await syncSession(sessionId); - } else if (__DEV__) { - console.warn('[InlineAuthView] Auth event received but no sessionId in data:', data); - } - } - }, - [syncSession], - ); - - if (!isNativeSupported || !NativeClerkAuthView) { - return ( - - - {!isNativeSupported - ? 'Native InlineAuthView is only available on iOS and Android' - : 'Native InlineAuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} - - - ); - } - - return ( - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - fallback: { - justifyContent: 'center', - alignItems: 'center', - }, - text: { - fontSize: 16, - color: '#666', - }, -}); diff --git a/packages/expo/src/native/InlineUserProfileView.tsx b/packages/expo/src/native/InlineUserProfileView.tsx deleted file mode 100644 index ecf38f46214..00000000000 --- a/packages/expo/src/native/InlineUserProfileView.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useClerk } from '@clerk/react'; -import { useCallback, useRef } from 'react'; -import { type StyleProp, StyleSheet, Text, View, type ViewStyle } from 'react-native'; - -import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView'; -import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; - -export interface InlineUserProfileViewProps { - /** - * Whether the profile view can be dismissed by the user. - * @default false - */ - isDismissable?: boolean; - - /** - * Style applied to the container view. - */ - style?: StyleProp; -} - -/** - * An inline native user profile component that renders in-place. - * - * `InlineUserProfileView` renders directly within your React Native view hierarchy. - * - * Sign-out is detected automatically and synced with the JS SDK. Use `useAuth()` in a - * `useEffect` to react to sign-out. - * - * @example - * ```tsx - * import { InlineUserProfileView } from '@clerk/expo/native'; - * import { useAuth } from '@clerk/expo'; - * - * export default function ProfileScreen() { - * const { isSignedIn } = useAuth(); - * - * useEffect(() => { - * if (!isSignedIn) router.replace('/sign-in'); - * }, [isSignedIn]); - * - * return ; - * } - * ``` - */ -export function InlineUserProfileView({ isDismissable = false, style }: InlineUserProfileViewProps) { - const clerk = useClerk(); - const signOutTriggered = useRef(false); - - const handleProfileEvent = useCallback( - async (event: { nativeEvent: { type: string; data: string } }) => { - const { type } = event.nativeEvent; - - if (type === 'signedOut' && !signOutTriggered.current) { - signOutTriggered.current = true; - - // Clear native session - try { - await ClerkExpo?.signOut(); - } catch (e) { - if (__DEV__) { - console.warn('[InlineUserProfileView] Native signOut error (may already be signed out):', e); - } - } - - // Sign out from JS SDK - if (clerk?.signOut) { - try { - await clerk.signOut(); - } catch (err) { - if (__DEV__) { - console.warn('[InlineUserProfileView] JS SDK sign out error:', err); - } - } - } - } - }, - [clerk], - ); - - if (!isNativeSupported || !NativeClerkUserProfileView) { - return ( - - - {!isNativeSupported - ? 'Native InlineUserProfileView is only available on iOS and Android' - : 'Native InlineUserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} - - - ); - } - - return ( - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - text: { - fontSize: 16, - color: '#666', - }, -}); From 969006d57e824c9e0ade9a5217ca93248909c4fa Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 28 May 2026 16:24:49 -0400 Subject: [PATCH 05/14] Remove Expo prebuilt user profile presentation path --- .changeset/remove-expo-present-auth.md | 4 +- .../expo/android/src/main/AndroidManifest.xml | 6 - .../expo/modules/clerk/ClerkExpoModule.kt | 105 +------------ .../modules/clerk/ClerkUserProfileActivity.kt | 130 ---------------- packages/expo/ios/ClerkExpoModule.m | 4 - packages/expo/ios/ClerkExpoModule.swift | 110 +++++--------- packages/expo/ios/ClerkViewFactory.swift | 84 ---------- packages/expo/src/hooks/index.ts | 1 - .../expo/src/hooks/useUserProfileModal.ts | 133 ---------------- packages/expo/src/native/UserButton.tsx | 143 +++++------------- packages/expo/src/native/UserProfileView.tsx | 20 ++- packages/expo/src/native/index.ts | 2 +- packages/expo/src/specs/NativeClerkModule.ts | 1 - 13 files changed, 97 insertions(+), 646 deletions(-) delete mode 100644 packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt delete mode 100644 packages/expo/src/hooks/useUserProfileModal.ts diff --git a/.changeset/remove-expo-present-auth.md b/.changeset/remove-expo-present-auth.md index 35781bbdd0f..5ba0b1094a9 100644 --- a/.changeset/remove-expo-present-auth.md +++ b/.changeset/remove-expo-present-auth.md @@ -1,5 +1,5 @@ --- -'@clerk/expo': patch +'@clerk/expo': major --- -Remove unused Expo prebuilt-view bridge code: the native `presentAuth` bridge, the Android auth activity/factory path, and stale `Inline*` source files now superseded by `AuthView` and `UserProfileView`. +Remove unused Expo prebuilt-view bridge code: the native `presentAuth` and `presentUserProfile` bridges, Android presentation activities/factory paths, the user-profile modal hook, and stale `Inline*` source files now superseded by app-presented `AuthView` and `UserProfileView`. diff --git a/packages/expo/android/src/main/AndroidManifest.xml b/packages/expo/android/src/main/AndroidManifest.xml index b60cb278cf8..e1131a6c37e 100644 --- a/packages/expo/android/src/main/AndroidManifest.xml +++ b/packages/expo/android/src/main/AndroidManifest.xml @@ -1,10 +1,4 @@ - - diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index d13728f2ec6..43676b6b020 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -1,8 +1,6 @@ package expo.modules.clerk -import android.app.Activity import android.content.Context -import android.content.Intent import android.util.Log import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -11,11 +9,9 @@ import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.ui.ClerkColors import com.clerk.api.ui.ClerkDesign import com.clerk.api.ui.ClerkTheme -import com.facebook.react.bridge.ActivityEventListener import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod -import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableNativeMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -34,33 +30,10 @@ private fun debugLog(tag: String, message: String) { } class ClerkExpoModule(reactContext: ReactApplicationContext) : - NativeClerkModuleSpec(reactContext), - ActivityEventListener { - - companion object { - const val CLERK_PROFILE_REQUEST_CODE = 9002 - - // Intent extras - const val EXTRA_DISMISSABLE = "dismissable" - const val EXTRA_PUBLISHABLE_KEY = "publishableKey" - - // Result extras - const val RESULT_SESSION_ID = "sessionId" - const val RESULT_CANCELLED = "cancelled" - - // Pending promises for activity results - private var pendingProfilePromise: Promise? = null - - // Store publishable key for passing to activities - private var publishableKey: String? = null - } + NativeClerkModuleSpec(reactContext) { private val coroutineScope = CoroutineScope(Dispatchers.Main) - init { - reactContext.addActivityEventListener(this) - } - override fun getName(): String = "ClerkExpo" // MARK: - configure @@ -69,8 +42,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : override fun configure(pubKey: String, bearerToken: String?, promise: Promise) { coroutineScope.launch { try { - publishableKey = pubKey - if (!Clerk.isInitialized.value) { // First-time initialization — write the bearer token to SharedPreferences // before initializing so the SDK boots with the correct client. @@ -154,33 +125,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } } - // MARK: - presentUserProfile - - @ReactMethod - override fun presentUserProfile(options: ReadableMap, promise: Promise) { - val activity = getCurrentActivity() ?: run { - promise.reject("E_ACTIVITY_UNAVAILABLE", "No activity available to present Clerk UI.") - return - } - - if (!Clerk.isInitialized.value) { - promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") - return - } - - pendingProfilePromise?.reject("E_SUPERSEDED", "Profile presentation was superseded") - pendingProfilePromise = promise - - val dismissable = if (options.hasKey("dismissable")) options.getBoolean("dismissable") else true - - val intent = Intent(activity, ClerkUserProfileActivity::class.java).apply { - putExtra(EXTRA_DISMISSABLE, dismissable) - putExtra(EXTRA_PUBLISHABLE_KEY, publishableKey) - } - - activity.startActivityForResult(intent, CLERK_PROFILE_REQUEST_CODE) - } - // MARK: - getSession @ReactMethod @@ -265,53 +209,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } } - // MARK: - Activity Result Handling - - override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) { - when (requestCode) { - CLERK_PROFILE_REQUEST_CODE -> handleProfileResult(resultCode, data) - } - } - - override fun onNewIntent(intent: Intent) { - // Not used - } - - private fun handleProfileResult(resultCode: Int, data: Intent?) { - val promise = pendingProfilePromise ?: return - pendingProfilePromise = null - - // Profile always returns current session state - val session = Clerk.session - val user = Clerk.user - - val result = WritableNativeMap() - - session?.let { - val sessionMap = WritableNativeMap() - sessionMap.putString("id", it.id) - sessionMap.putString("status", it.status.name) - sessionMap.putString("userId", it.user?.id) - result.putMap("session", sessionMap) - } - - user?.let { - val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId } - - val userMap = WritableNativeMap() - userMap.putString("id", it.id) - userMap.putString("firstName", it.firstName) - userMap.putString("lastName", it.lastName) - userMap.putString("imageUrl", it.imageUrl) - userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress) - result.putMap("user", userMap) - } - - result.putBoolean("dismissed", resultCode == Activity.RESULT_CANCELED) - - promise.resolve(result) - } - // MARK: - Theme Loading private fun loadThemeFromAssets() { diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt deleted file mode 100644 index f68b4e30bd8..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt +++ /dev/null @@ -1,130 +0,0 @@ -package expo.modules.clerk - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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 - -/** - * Activity that hosts the Clerk UserProfileView composable. - * Presents the native user profile UI and returns the result when dismissed. - */ -class ClerkUserProfileActivity : ComponentActivity() { - - companion object { - private const val TAG = "ClerkUserProfileActivity" - - private fun debugLog(tag: String, message: String) { - if (BuildConfig.DEBUG) { - Log.d(tag, message) - } - } - } - - private var dismissed = false - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - - val dismissable = intent.getBooleanExtra(ClerkExpoModule.EXTRA_DISMISSABLE, true) - val publishableKey = intent.getStringExtra(ClerkExpoModule.EXTRA_PUBLISHABLE_KEY) - - debugLog(TAG, "onCreate - isInitialized: ${Clerk.isInitialized.value}") - debugLog(TAG, "onCreate - hasSession: ${Clerk.session != null}, hasUser: ${Clerk.user != null}") - - // Initialize Clerk if not already initialized - if (publishableKey != null && !Clerk.isInitialized.value) { - debugLog(TAG, "Initializing Clerk...") - Clerk.initialize(applicationContext, publishableKey) - } - - setContent { - // Observe user state changes - val user by Clerk.userFlow.collectAsStateWithLifecycle() - val session by Clerk.sessionFlow.collectAsStateWithLifecycle() - - // Track if we had a session when the profile opened (to detect sign-out) - var hadSession by remember { mutableStateOf(Clerk.session != null) } - - // Log when user/session state changes - LaunchedEffect(user, session) { - debugLog(TAG, "State changed - hasSession: ${session != null}, hasUser: ${user != null}") - } - - // 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") - // 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) - if (session != null) { - hadSession = true - } - } - - MaterialTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - UserProfileView( - clerkTheme = Clerk.customTheme, - onDismiss = { - finishWithSuccess() - } - ) - } - } - } - - // Handle back press via onBackPressedDispatcher (replaces deprecated onBackPressed) - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (dismissable) { - finishWithSuccess() - } - // Otherwise ignore back press - } - }) - } - - private fun finishWithSuccess() { - if (dismissed) return - dismissed = true - - val result = Intent() - result.putExtra(ClerkExpoModule.RESULT_SESSION_ID, Clerk.session?.id) - result.putExtra(ClerkExpoModule.RESULT_CANCELLED, false) - setResult(Activity.RESULT_OK, result) - finish() - } -} diff --git a/packages/expo/ios/ClerkExpoModule.m b/packages/expo/ios/ClerkExpoModule.m index 6ca533d1593..3e6bad35035 100644 --- a/packages/expo/ios/ClerkExpoModule.m +++ b/packages/expo/ios/ClerkExpoModule.m @@ -8,10 +8,6 @@ @interface RCT_EXTERN_MODULE(ClerkExpo, RCTEventEmitter) resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(presentUserProfile:(NSDictionary *)options - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) - RCT_EXTERN_METHOD(getSession:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index 3d85c175e81..ce64362754b 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -11,8 +11,6 @@ public var clerkViewFactory: ClerkViewFactoryProtocol? // Protocol that the app target implements to provide Clerk views public protocol ClerkViewFactoryProtocol { - func createUserProfileViewController(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) -> UIViewController? - // Inline rendering — returns UIViewController to preserve SwiftUI lifecycle func createAuthView(mode: String, dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController? func createUserProfileView(dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController? @@ -64,21 +62,6 @@ class ClerkExpoModule: RCTEventEmitter { ]) } - /// Returns the topmost presented view controller, avoiding deprecated `keyWindow`. - 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 - } - // MARK: - configure @objc func configure(_ publishableKey: String, @@ -100,39 +83,6 @@ class ClerkExpoModule: RCTEventEmitter { } } - // MARK: - presentUserProfile - - @objc func presentUserProfile(_ options: NSDictionary, - resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { - guard let factory = clerkViewFactory else { - reject("E_NOT_INITIALIZED", "Clerk not initialized", nil) - return - } - - let dismissable = options["dismissable"] as? Bool ?? true - - DispatchQueue.main.async { - guard let vc = factory.createUserProfileViewController(dismissable: dismissable, completion: { result in - switch result { - case .success(let data): - resolve(data) - case .failure(let error): - reject("E_PROFILE_FAILED", error.localizedDescription, error) - } - }) else { - reject("E_CREATE_FAILED", "Could not create profile view controller", nil) - return - } - - if let rootVC = Self.topViewController() { - rootVC.present(vc, animated: true) - } else { - reject("E_NO_ROOT_VC", "No root view controller available to present profile", nil) - } - } - } - // MARK: - getSession @objc func getSession(_ resolve: @escaping RCTPromiseResolveBlock, @@ -306,14 +256,16 @@ public class ClerkAuthNativeView: UIView { public class ClerkUserProfileNativeView: UIView { private var hostingController: UIViewController? - private var currentDismissable: Bool = true + private var currentDismissable: Bool = false private var hasInitialized: Bool = false + private var didSignOut = false + private var dismissalEventSent = false @objc var onProfileEvent: RCTBubblingEventBlock? @objc var isDismissable: NSNumber? { didSet { - currentDismissable = isDismissable?.boolValue ?? true + currentDismissable = isDismissable?.boolValue ?? false if hasInitialized { updateView() } } } @@ -331,25 +283,32 @@ public class ClerkUserProfileNativeView: UIView { if window != nil && !hasInitialized { hasInitialized = true updateView() + } else if window == nil && hasInitialized && currentDismissable && !didSignOut && !dismissalEventSent { + dismissalEventSent = true + sendProfileEvent(type: "dismissed", data: [:]) } } + private func sendProfileEvent(type: String, data: [String: Any]) { + let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data() + let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" + onProfileEvent?(["type": type, "data": jsonString]) + } + private func updateView() { - // Remove old hosting controller - hostingController?.view.removeFromSuperview() - hostingController?.removeFromParent() - hostingController = nil + detachHostingController() guard let factory = clerkViewFactory else { return } guard let returnedController = factory.createUserProfileView( dismissable: currentDismissable, onEvent: { [weak self] eventName, data in - let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data() - let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" - self?.onProfileEvent?(["type": eventName, "data": jsonString]) + if eventName == "signedOut" { + self?.didSignOut = true + } + + self?.sendProfileEvent(type: eventName, data: data) - // Also emit module-level event for sign-out detection if eventName == "signedOut" { let sessionId = data["sessionId"] as? String ClerkExpoModule.emitAuthStateChange(type: "signedOut", sessionId: sessionId) @@ -357,19 +316,30 @@ public class ClerkUserProfileNativeView: UIView { } ) else { return } + attachHostingController(returnedController) + } + + private func attachHostingController(_ controller: UIViewController) { + controller.view.frame = bounds + controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + 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 + parentVC.addChild(controller) + addSubview(controller.view) + controller.didMove(toParent: parentVC) } else { - returnedController.view.frame = bounds - returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - addSubview(returnedController.view) - hostingController = returnedController + addSubview(controller.view) } + + hostingController = controller + } + + private func detachHostingController() { + guard let controller = hostingController else { return } + controller.willMove(toParent: nil) + controller.view.removeFromSuperview() + controller.removeFromParent() + hostingController = nil } private func findViewController() -> UIViewController? { diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index e67c44b8efa..1d66bb72ed4 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -149,19 +149,6 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { Self.readNativeDeviceToken() } - public func createUserProfileViewController( - dismissable: Bool, - completion: @escaping (Result<[String: Any], Error>) -> Void - ) -> UIViewController? { - let wrapper = ClerkProfileWrapperViewController( - dismissable: dismissable, - lightTheme: lightTheme, - darkTheme: darkTheme, - completion: completion - ) - return wrapper - } - // MARK: - Inline View Creation public func createAuthView( @@ -405,77 +392,6 @@ private struct ExpoKeychain { } } -// MARK: - Profile View Controller Wrapper - -class ClerkProfileWrapperViewController: UIHostingController { - private let completion: (Result<[String: Any], Error>) -> Void - private var authEventTask: Task? - private var completionCalled = false - - init(dismissable: Bool, lightTheme: ClerkTheme?, darkTheme: ClerkTheme?, completion: @escaping (Result<[String: Any], Error>) -> Void) { - self.completion = completion - let view = ClerkProfileWrapperView(dismissable: dismissable, lightTheme: lightTheme, darkTheme: darkTheme) - super.init(rootView: view) - self.modalPresentationStyle = .fullScreen - subscribeToAuthEvents() - } - - @MainActor required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - authEventTask?.cancel() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - if isBeingDismissed { - completeOnce(.success(["dismissed": true])) - } - } - - private func completeOnce(_ result: Result<[String: Any], Error>) { - guard !completionCalled else { return } - completionCalled = true - completion(result) - } - - private func subscribeToAuthEvents() { - authEventTask = Task { @MainActor [weak self] in - for await event in Clerk.shared.auth.events { - guard let self = self, !self.completionCalled else { return } - switch event { - case .signedOut(let session): - self.completeOnce(.success(["sessionId": session.id])) - self.dismiss(animated: true) - default: - break - } - } - } - } -} - -struct ClerkProfileWrapperView: View { - let dismissable: Bool - let lightTheme: ClerkTheme? - let darkTheme: ClerkTheme? - - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - let view = UserProfileView(isDismissable: dismissable) - .environment(Clerk.shared) - let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme - if let theme { - view.environment(\.clerkTheme, theme) - } else { - view - } - } -} - // MARK: - Inline Auth View Wrapper (for embedded rendering) struct ClerkInlineAuthWrapperView: View { diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts index 6c7f22b4d43..ef41c5609ec 100644 --- a/packages/expo/src/hooks/index.ts +++ b/packages/expo/src/hooks/index.ts @@ -19,4 +19,3 @@ export * from './useOAuth'; export * from './useAuth'; export * from './useNativeSession'; export * from './useNativeAuthEvents'; -export * from './useUserProfileModal'; diff --git a/packages/expo/src/hooks/useUserProfileModal.ts b/packages/expo/src/hooks/useUserProfileModal.ts deleted file mode 100644 index da7c6f4d081..00000000000 --- a/packages/expo/src/hooks/useUserProfileModal.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { useClerk, useUser } from '@clerk/react'; -import { useCallback, useRef } from 'react'; - -import { CLERK_CLIENT_JWT_KEY } from '../constants'; -import { tokenCache } from '../token-cache'; -import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; - -// Raw result from the native module (may vary by platform) -type NativeSessionResult = { - sessionId?: string; - session?: { id: string }; -}; - -export interface UseUserProfileModalReturn { - /** - * Present the native user profile modal. - * - * The returned promise resolves when the modal is dismissed. - * If the user signed out from within the profile modal, - * the JS SDK session is automatically cleared. - */ - presentUserProfile: () => Promise; - - /** - * Whether the native module supports presenting the profile modal. - */ - isAvailable: boolean; -} - -/** - * Imperative hook for presenting the native user profile modal. - * - * Call `presentUserProfile()` from a button's `onPress` to show the native - * profile management screen (SwiftUI on iOS, Jetpack Compose on Android). - * The promise resolves when the modal is dismissed. - * - * Sign-out is detected automatically — if the user signs out from within - * the profile modal, the JS SDK session is cleared so `useAuth()` updates - * reactively. - * - * @example - * ```tsx - * import { useUserProfileModal } from '@clerk/expo'; - * - * function MyScreen() { - * const { presentUserProfile } = useUserProfileModal(); - * - * return ( - * - * Manage Profile - * - * ); - * } - * ``` - */ -export function useUserProfileModal(): UseUserProfileModalReturn { - const clerk = useClerk(); - const { user } = useUser(); - const presentingRef = useRef(false); - - const presentUserProfile = useCallback(async () => { - if (presentingRef.current) { - return; - } - - if (!isNativeSupported || !ClerkExpo?.presentUserProfile) { - return; - } - - presentingRef.current = true; - try { - let hadNativeSessionBefore = false; - - // If native doesn't have a session but JS does (e.g. user signed in via custom form), - // sync the JS SDK's bearer token to native and wait for it before presenting. - if (user && ClerkExpo?.getSession && ClerkExpo?.configure) { - const preCheck = (await ClerkExpo.getSession()) as NativeSessionResult | null; - hadNativeSessionBefore = !!(preCheck?.sessionId || preCheck?.session?.id); - - if (!hadNativeSessionBefore) { - const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; - if (bearerToken) { - await ClerkExpo.configure(clerk.publishableKey, bearerToken); - - // Re-check if configure produced a session - const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null; - hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id); - } - } - } - - await ClerkExpo.presentUserProfile({ - dismissable: true, - }); - - // Only sign out the JS SDK if native HAD a session before the modal - // and now it's gone (user signed out from within native UI). - const sessionCheck = (await ClerkExpo.getSession?.()) as NativeSessionResult | null; - const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id); - - if (!hasNativeSession && hadNativeSessionBefore) { - try { - await ClerkExpo.signOut?.(); - } catch (e) { - if (__DEV__) { - console.warn('[useUserProfileModal] Native signOut error (may already be signed out):', e); - } - } - - if (clerk?.signOut) { - try { - await clerk.signOut(); - } catch (e) { - if (__DEV__) { - console.warn('[useUserProfileModal] Best-effort JS SDK signOut failed:', e); - } - } - } - } - } catch (error) { - if (__DEV__) { - console.error('[useUserProfileModal] presentUserProfile failed:', error); - } - } finally { - presentingRef.current = false; - } - }, [clerk, user]); - - return { - presentUserProfile, - isAvailable: isNativeSupported && !!ClerkExpo?.presentUserProfile, - }; -} diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx index 4e0795970ff..af029177c56 100644 --- a/packages/expo/src/native/UserButton.tsx +++ b/packages/expo/src/native/UserButton.tsx @@ -1,9 +1,8 @@ -import { useClerk, useUser } from '@clerk/react'; -import { useEffect, useRef, useState } from 'react'; +import { useUser } from '@clerk/react'; +import { useEffect, useState } from 'react'; +import type { GestureResponderEvent, StyleProp, ViewStyle } from 'react-native'; import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { CLERK_CLIENT_JWT_KEY } from '../constants'; -import { tokenCache } from '../token-cache'; import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; // Raw result from native module (may vary by platform) @@ -33,17 +32,26 @@ interface NativeUser { /** * Props for the UserButton component. */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface UserButtonProps {} +export interface UserButtonProps { + /** + * Called when the button is pressed. + * + * Use this to present your own `UserProfileView` sheet, modal, or route. + */ + onPress?: (event: GestureResponderEvent) => void; + + /** + * Style applied to the button container. + */ + style?: StyleProp; +} /** - * A pre-built native button component that displays the user's avatar and opens their profile. + * A pre-built button component that displays the user's avatar. * * `UserButton` renders a circular button showing the user's profile image (or initials if - * no image is available). When tapped, it presents the native profile management modal. - * - * Sign-out is detected automatically and synced with the JS SDK, causing `useAuth()` to - * update reactively. Use `useAuth()` in a `useEffect` to react to sign-out. + * no image is available). Use `onPress` to present your own `UserProfileView` sheet, + * modal, or route. * * @example Basic usage in a header * ```tsx @@ -59,30 +67,32 @@ export interface UserButtonProps {} * } * ``` * - * @example Reacting to sign-out + * @example Presenting the profile in an app-owned modal * ```tsx - * import { UserButton } from '@clerk/expo/native'; - * import { useAuth } from '@clerk/expo'; + * import { UserButton, UserProfileView } from '@clerk/expo/native'; * * export default function Header() { - * const { isSignedIn } = useAuth(); + * const [isProfileOpen, setIsProfileOpen] = useState(false); * - * useEffect(() => { - * if (!isSignedIn) router.replace('/sign-in'); - * }, [isSignedIn]); - * - * return ; + * return ( + * <> + * setIsProfileOpen(true)} + * style={{ width: 40, height: 40 }} + * /> + * + * + * + * + * ); * } * ``` * - * @see {@link UserProfileView} The profile view that opens when tapped + * @see {@link UserProfileView} The profile view to render in your own presentation surface * @see {@link https://clerk.com/docs/components/user/user-button} Clerk UserButton Documentation */ -export function UserButton(_props: UserButtonProps) { +export function UserButton({ onPress, style }: UserButtonProps) { const [nativeUser, setNativeUser] = useState(null); - const presentingRef = useRef(false); - const clerk = useClerk(); - // Use the reactive user hook from clerk-react to observe sign-out state changes const { user: clerkUser } = useUser(); // Fetch native user data on mount and when clerk user changes @@ -124,87 +134,10 @@ export function UserButton(_props: UserButtonProps) { } : null); - const handlePress = async () => { - if (presentingRef.current) { - return; - } - - if (!isNativeSupported || !ClerkExpo?.presentUserProfile) { - return; - } - - presentingRef.current = true; - try { - // Track whether native had a session before the modal, so we can distinguish - // "user signed out from within the modal" from "native never had a session". - let hadNativeSessionBefore = false; - - // If native doesn't have a session but JS does (e.g. user signed in via custom form), - // sync the JS SDK's bearer token to native and wait for it before presenting. - if (clerkUser && ClerkExpo?.getSession && ClerkExpo?.configure) { - const preCheck = (await ClerkExpo.getSession()) as NativeSessionResult | null; - hadNativeSessionBefore = !!(preCheck?.sessionId || preCheck?.session?.id); - - if (!hadNativeSessionBefore) { - const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; - if (bearerToken) { - await ClerkExpo.configure(clerk.publishableKey, bearerToken); - - // Re-check if configure produced a session - const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null; - hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id); - } - } - } - - await ClerkExpo.presentUserProfile({ - dismissable: true, - }); - - // Check if native session still exists after modal closes. - // Only sign out the JS SDK if the native SDK HAD a session before the modal - // and now it's gone (meaning the user signed out from within the native UI). - // If native never had a session (e.g. force refresh didn't work), don't sign out JS. - const sessionCheck = (await ClerkExpo.getSession?.()) as NativeSessionResult | null; - const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id); - - if (!hasNativeSession && hadNativeSessionBefore) { - // Clear local state immediately for instant UI feedback - setNativeUser(null); - - // Clear native session explicitly (may already be cleared, but ensure it) - try { - await ClerkExpo.signOut?.(); - } catch (e) { - if (__DEV__) { - console.warn('[UserButton] Native signOut error (may already be signed out):', e); - } - } - - // Sign out from JS SDK to update isSignedIn state - if (clerk?.signOut) { - try { - await clerk.signOut(); - } catch (e) { - if (__DEV__) { - console.warn('[UserButton] JS SDK signOut error:', e); - } - } - } - } - } catch (error) { - if (__DEV__) { - console.error('[UserButton] presentUserProfile failed:', error); - } - } finally { - presentingRef.current = false; - } - }; - // Show fallback when native modules aren't available if (!isNativeSupported || !ClerkExpo) { return ( - + ? ); @@ -212,8 +145,8 @@ export function UserButton(_props: UserButtonProps) { return ( void handlePress()} - style={styles.button} + onPress={onPress} + style={[styles.button, style]} > {user?.imageUrl ? ( ; + + /** + * Called when the user dismisses the native profile view. + */ + onDismiss?: () => void; } /** @@ -33,7 +38,7 @@ export interface UserProfileViewProps { * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android * - * To present the profile as a native modal, use the `useUserProfileModal()` hook instead. + * To present the profile, render it inside your own `Modal`, sheet, or route. * * Sign-out is detected automatically and synced with the JS SDK. Use `useAuth()` in a * `useEffect` to react to sign-out. @@ -56,7 +61,7 @@ export interface UserProfileViewProps { * * @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation */ -export function UserProfileView({ isDismissable = false, style }: UserProfileViewProps) { +export function UserProfileView({ isDismissable = false, style, onDismiss }: UserProfileViewProps) { const clerk = useClerk(); const signOutTriggered = useRef(false); @@ -64,6 +69,11 @@ export function UserProfileView({ isDismissable = false, style }: UserProfileVie async (event: { nativeEvent: { type: string; data: string } }) => { const { type } = event.nativeEvent; + if (type === 'dismissed') { + onDismiss?.(); + return; + } + if (type === 'signedOut' && !signOutTriggered.current) { signOutTriggered.current = true; @@ -86,7 +96,7 @@ export function UserProfileView({ isDismissable = false, style }: UserProfileVie } } }, - [clerk], + [clerk, onDismiss], ); if (!isNativeSupported || !NativeClerkUserProfileView) { diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts index 8ccd60b6f2c..8733e8f3189 100644 --- a/packages/expo/src/native/index.ts +++ b/packages/expo/src/native/index.ts @@ -23,7 +23,7 @@ * * - {@link AuthView} - Authentication flow (sign-in/sign-up), renders inline * - {@link UserProfileView} - User profile and account management, renders inline - * - {@link UserButton} - Avatar button that opens native profile modal + * - {@link UserButton} - Avatar button for opening your own profile surface * * @module @clerk/expo/native */ diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index 527a40e0882..de483c672da 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -4,7 +4,6 @@ import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypesName export interface Spec extends TurboModule { configure(publishableKey: string, bearerToken: string | null): Promise; - presentUserProfile(options: UnsafeObject): Promise; getSession(): Promise; getClientToken(): Promise; signOut(): Promise; From 3969b5f8163353eaa66697e19f494272027b4f6d Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 28 May 2026 16:36:19 -0400 Subject: [PATCH 06/14] Make UserButton circular by default --- packages/expo/src/native/UserButton.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx index af029177c56..f16e1a23797 100644 --- a/packages/expo/src/native/UserButton.tsx +++ b/packages/expo/src/native/UserButton.tsx @@ -164,8 +164,9 @@ export function UserButton({ onPress, style }: UserButtonProps) { const styles = StyleSheet.create({ button: { - width: '100%', - height: '100%', + width: 40, + height: 40, + borderRadius: 9999, overflow: 'hidden', }, avatar: { From 13198e1dc01b9a4bab14124228d8ac1822096fe1 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 28 May 2026 17:44:31 -0400 Subject: [PATCH 07/14] Align Expo UserButton with native SDKs --- .changeset/remove-expo-present-auth.md | 2 + .../expo/modules/clerk/ClerkAuthExpoView.kt | 24 +- .../expo/modules/clerk/ClerkExpoModule.kt | 40 +++ .../java/expo/modules/clerk/ClerkPackage.kt | 1 + .../modules/clerk/ClerkUserButtonExpoView.kt | 116 ++++++++ .../clerk/ClerkUserButtonViewManager.kt | 13 + .../modules/clerk/ClerkUserProfileExpoView.kt | 12 +- packages/expo/ios/ClerkExpo.podspec | 3 +- packages/expo/ios/ClerkExpoModule.swift | 110 +++++-- .../expo/ios/ClerkUserButtonViewManager.m | 5 + .../expo/ios/ClerkUserButtonViewManager.swift | 13 + packages/expo/ios/ClerkViewFactory.swift | 50 +++- .../expo/src/hooks/useNativeAuthEvents.ts | 7 +- packages/expo/src/native/AuthView.tsx | 100 +------ packages/expo/src/native/UserButton.tsx | 185 +----------- packages/expo/src/native/UserProfileView.tsx | 39 +-- packages/expo/src/native/index.ts | 3 +- packages/expo/src/provider/ClerkProvider.tsx | 281 ++++++++++-------- .../expo/src/specs/NativeClerkAuthView.ts | 2 +- packages/expo/src/specs/NativeClerkModule.ts | 6 + .../src/specs/NativeClerkUserButtonView.ts | 9 + .../src/specs/NativeClerkUserProfileView.ts | 2 +- 22 files changed, 533 insertions(+), 490 deletions(-) create mode 100644 packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonExpoView.kt create mode 100644 packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewManager.kt create mode 100644 packages/expo/ios/ClerkUserButtonViewManager.m create mode 100644 packages/expo/ios/ClerkUserButtonViewManager.swift create mode 100644 packages/expo/src/specs/NativeClerkUserButtonView.ts diff --git a/.changeset/remove-expo-present-auth.md b/.changeset/remove-expo-present-auth.md index 5ba0b1094a9..46172be3bea 100644 --- a/.changeset/remove-expo-present-auth.md +++ b/.changeset/remove-expo-present-auth.md @@ -3,3 +3,5 @@ --- Remove unused Expo prebuilt-view bridge code: the native `presentAuth` and `presentUserProfile` bridges, Android presentation activities/factory paths, the user-profile modal hook, and stale `Inline*` source files now superseded by app-presented `AuthView` and `UserProfileView`. + +Align `UserButton` with the native Clerk SDKs by wrapping the platform-native user button, letting it present the user profile from the button itself. diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt index ce948f7a8a4..29b3e19a7d0 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt @@ -26,9 +26,6 @@ import androidx.savedstate.compose.LocalSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.clerk.api.Clerk import com.clerk.ui.auth.AuthView -import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.ReactContext -import com.facebook.react.uimanager.events.RCTEventEmitter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -117,10 +114,7 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) { 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" - )) + ClerkExpoModule.emitAuthStateChange("signedIn", currentSession.id) } } @@ -156,22 +150,6 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) { } } - private fun sendEvent(type: String, data: Map) { - val reactContext = context as? ReactContext ?: return - val eventData = Arguments.createMap().apply { - putString("type", type) - // Serialize data as JSON string for codegen event - val jsonString = try { - org.json.JSONObject(data).toString() - } catch (e: Exception) { - "{}" - } - putString("data", jsonString) - } - reactContext.getJSModule(RCTEventEmitter::class.java) - .receiveEvent(id, "onAuthEvent", eventData) - } - companion object { fun findActivity(context: Context): ComponentActivity? { var ctx: Context? = context diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index 43676b6b020..eea8c1043f9 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -9,10 +9,12 @@ import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.ui.ClerkColors import com.clerk.api.ui.ClerkDesign import com.clerk.api.ui.ClerkTheme +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.WritableNativeMap +import com.facebook.react.modules.core.DeviceEventManagerModule import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException @@ -34,8 +36,46 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : private val coroutineScope = CoroutineScope(Dispatchers.Main) + companion object { + private var sharedReactContext: ReactApplicationContext? = null + private var listenerCount = 0 + + fun emitAuthStateChange(type: String, sessionId: String?) { + if (listenerCount <= 0) { + return + } + + val event = Arguments.createMap().apply { + putString("type", type) + if (sessionId == null) { + putNull("sessionId") + } else { + putString("sessionId", sessionId) + } + } + + sharedReactContext + ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + ?.emit("onAuthStateChange", event) + } + } + + init { + sharedReactContext = reactContext + } + override fun getName(): String = "ClerkExpo" + @ReactMethod + override fun addListener(eventName: String) { + listenerCount += 1 + } + + @ReactMethod + override fun removeListeners(count: Double) { + listenerCount = maxOf(0, listenerCount - count.toInt()) + } + // MARK: - configure @ReactMethod diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt index 9a97309ac5e..dc52634b76d 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt @@ -38,6 +38,7 @@ class ClerkPackage : TurboReactPackage() { return listOf( ClerkAuthViewManager(), ClerkUserProfileViewManager(), + ClerkUserButtonViewManager(), ) } } diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonExpoView.kt new file mode 100644 index 00000000000..f0f5a79a20a --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonExpoView.kt @@ -0,0 +1,116 @@ +package expo.modules.clerk + +import android.content.Context +import android.util.Log +import android.widget.FrameLayout +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.AndroidUiDispatcher +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +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.userbutton.UserButton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private const val USER_BUTTON_TAG = "ClerkUserButtonExpoView" + +class ClerkUserButtonNativeView(context: Context) : FrameLayout(context) { + private val activity = ClerkAuthNativeView.findActivity(context).also { + if (it != null) Clerk.attachActivity(it) + } + + private var recomposer: Recomposer? = null + private var recomposerJob: kotlinx.coroutines.Job? = null + + private val composeView = ComposeView(context).also { view -> + activity?.let { act -> + view.setViewTreeLifecycleOwner(act) + view.setViewTreeViewModelStoreOwner(act) + view.setViewTreeSavedStateRegistryOwner(act) + + val recomposerContext = AndroidUiDispatcher.Main + val newRecomposer = Recomposer(recomposerContext) + recomposer = newRecomposer + view.setParentCompositionContext(newRecomposer) + val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob()) + recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job] + scope.launch { + newRecomposer.runRecomposeAndApplyChanges() + } + } + addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + setupView() + } + + override fun onDetachedFromWindow() { + recomposer?.cancel() + recomposerJob?.cancel() + super.onDetachedFromWindow() + } + + private fun setupView() { + composeView.setContent { + val session by Clerk.sessionFlow.collectAsStateWithLifecycle() + var hadSession by remember { mutableStateOf(Clerk.session != null) } + + LaunchedEffect(session) { + if (hadSession && session == null) { + try { + Client.getSkippingClientId() + } catch (e: Exception) { + Log.w(USER_BUTTON_TAG, "Client refresh after UserButton sign-out failed: ${e.message}") + } + ClerkExpoModule.emitAuthStateChange("signedOut", null) + } + if (session != null) { + hadSession = true + } + } + + val content = @androidx.compose.runtime.Composable { + MaterialTheme { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + UserButton(clerkTheme = Clerk.customTheme) + } + } + } + + if (activity != null) { + CompositionLocalProvider( + LocalViewModelStoreOwner provides activity, + LocalLifecycleOwner provides activity, + LocalSavedStateRegistryOwner provides activity, + ) { + content() + } + } else { + content() + } + } + } +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewManager.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewManager.kt new file mode 100644 index 00000000000..9c93eed0da5 --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewManager.kt @@ -0,0 +1,13 @@ +package expo.modules.clerk + +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext + +class ClerkUserButtonViewManager : SimpleViewManager() { + + override fun getName(): String = "ClerkUserButtonView" + + override fun createViewInstance(reactContext: ThemedReactContext): ClerkUserButtonNativeView { + return ClerkUserButtonNativeView(reactContext) + } +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt index 8d3762a3be6..9ab02767609 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt @@ -89,7 +89,7 @@ class ClerkUserProfileNativeView(context: Context) : FrameLayout(context) { } catch (e: Exception) { Log.w(TAG, "Client.getSkippingClientId() after UserProfile sign-out failed: ${e.message}") } - sendEvent("signedOut", emptyMap()) + ClerkExpoModule.emitAuthStateChange("signedOut", null) } if (session != null) { hadSession = true @@ -106,7 +106,7 @@ class ClerkUserProfileNativeView(context: Context) : FrameLayout(context) { clerkTheme = Clerk.customTheme, onDismiss = { Log.d(TAG, "Profile dismissed") - sendEvent("dismissed", emptyMap()) + sendEvent("dismissed") } ) } @@ -127,16 +127,10 @@ class ClerkUserProfileNativeView(context: Context) : FrameLayout(context) { } } - private fun sendEvent(type: String, data: Map) { + private fun sendEvent(type: String) { val reactContext = context as? ReactContext ?: return val eventData = Arguments.createMap().apply { putString("type", type) - val jsonString = try { - org.json.JSONObject(data).toString() - } catch (e: Exception) { - "{}" - } - putString("data", jsonString) } reactContext.getJSModule(RCTEventEmitter::class.java) .receiveEvent(id, "onProfileEvent", eventData) diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec index fbd91f9a91c..e511c8cb374 100644 --- a/packages/expo/ios/ClerkExpo.podspec +++ b/packages/expo/ios/ClerkExpo.podspec @@ -40,7 +40,8 @@ Pod::Spec.new do |s| # because it uses `import ClerkKit` which is only available via SPM in the app target. s.source_files = "ClerkExpoModule.swift", "ClerkExpoModule.m", "ClerkAuthViewManager.swift", "ClerkAuthViewManager.m", - "ClerkUserProfileViewManager.swift", "ClerkUserProfileViewManager.m" + "ClerkUserProfileViewManager.swift", "ClerkUserProfileViewManager.m", + "ClerkUserButtonViewManager.swift", "ClerkUserButtonViewManager.m" install_modules_dependencies(s) end diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index ce64362754b..4c9114df97e 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -14,6 +14,7 @@ public protocol ClerkViewFactoryProtocol { // Inline rendering — returns UIViewController to preserve SwiftUI lifecycle func createAuthView(mode: String, dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController? func createUserProfileView(dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController? + func createUserButton(onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController? // SDK operations func configure(publishableKey: String, bearerToken: String?) async throws @@ -52,8 +53,7 @@ class ClerkExpoModule: RCTEventEmitter { } /// Emits an onAuthStateChange event to JS from anywhere in the native layer. - /// Used by inline views (AuthView, UserProfileView) to notify ClerkProvider - /// of auth state changes in addition to the view-level onAuthEvent callback. + /// Used by native views to notify ClerkProvider of auth state changes. static func emitAuthStateChange(type: String, sessionId: String?) { guard _hasListeners, let instance = sharedInstance else { return } instance.sendEvent(withName: "onAuthStateChange", body: [ @@ -130,6 +130,86 @@ class ClerkExpoModule: RCTEventEmitter { } } +// MARK: - Inline View: ClerkUserButtonNativeView + +public class ClerkUserButtonNativeView: UIView { + private var hostingController: UIViewController? + private var hasInitialized: Bool = false + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func didMoveToWindow() { + super.didMoveToWindow() + if window != nil && !hasInitialized { + hasInitialized = true + updateView() + } + } + + private func updateView() { + detachHostingController() + + guard let factory = clerkViewFactory else { return } + + guard let returnedController = factory.createUserButton( + onEvent: { eventName, data in + if eventName == "signedOut" { + let sessionId = data["sessionId"] as? String + ClerkExpoModule.emitAuthStateChange(type: "signedOut", sessionId: sessionId) + } + } + ) else { return } + + attachHostingController(returnedController) + } + + private func attachHostingController(_ controller: UIViewController) { + controller.view.frame = bounds + controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + controller.view.backgroundColor = .clear + + if let parentVC = findViewController() { + parentVC.addChild(controller) + addSubview(controller.view) + controller.didMove(toParent: parentVC) + } else { + addSubview(controller.view) + } + + hostingController = controller + } + + private func detachHostingController() { + guard let controller = hostingController else { return } + controller.willMove(toParent: nil) + controller.view.removeFromSuperview() + controller.removeFromParent() + hostingController = nil + } + + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let nextResponder = responder?.next { + if let vc = nextResponder as? UIViewController { + return vc + } + responder = nextResponder + } + return nil + } + + override public func layoutSubviews() { + super.layoutSubviews() + hostingController?.view.frame = bounds + } +} + // MARK: - Inline View: ClerkAuthNativeView public class ClerkAuthNativeView: UIView { @@ -175,14 +255,12 @@ public class ClerkAuthNativeView: UIView { updateView() } else if window == nil && hasInitialized && currentDismissable && !didCompleteAuthentication && !dismissalEventSent { dismissalEventSent = true - sendAuthEvent(type: "dismissed", data: [:]) + sendAuthEvent(type: "dismissed") } } - 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]) + private func sendAuthEvent(type: String) { + onAuthEvent?(["type": type]) } private func updateView() { @@ -198,11 +276,6 @@ public class ClerkAuthNativeView: UIView { if didCompleteAuthentication { self?.didCompleteAuthentication = true - } - - self?.sendAuthEvent(type: eventName, data: data) - - if didCompleteAuthentication { let sessionId = data["sessionId"] as? String ClerkExpoModule.emitAuthStateChange(type: "signedIn", sessionId: sessionId) } @@ -285,14 +358,12 @@ public class ClerkUserProfileNativeView: UIView { updateView() } else if window == nil && hasInitialized && currentDismissable && !didSignOut && !dismissalEventSent { dismissalEventSent = true - sendProfileEvent(type: "dismissed", data: [:]) + sendProfileEvent(type: "dismissed") } } - private func sendProfileEvent(type: String, data: [String: Any]) { - let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data() - let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" - onProfileEvent?(["type": type, "data": jsonString]) + private func sendProfileEvent(type: String) { + onProfileEvent?(["type": type]) } private func updateView() { @@ -305,11 +376,6 @@ public class ClerkUserProfileNativeView: UIView { onEvent: { [weak self] eventName, data in if eventName == "signedOut" { self?.didSignOut = true - } - - self?.sendProfileEvent(type: eventName, data: data) - - if eventName == "signedOut" { let sessionId = data["sessionId"] as? String ClerkExpoModule.emitAuthStateChange(type: "signedOut", sessionId: sessionId) } diff --git a/packages/expo/ios/ClerkUserButtonViewManager.m b/packages/expo/ios/ClerkUserButtonViewManager.m new file mode 100644 index 00000000000..5d353edc6a4 --- /dev/null +++ b/packages/expo/ios/ClerkUserButtonViewManager.m @@ -0,0 +1,5 @@ +#import + +@interface RCT_EXTERN_MODULE(ClerkUserButtonViewManager, RCTViewManager) + +@end diff --git a/packages/expo/ios/ClerkUserButtonViewManager.swift b/packages/expo/ios/ClerkUserButtonViewManager.swift new file mode 100644 index 00000000000..ec7b9da51f9 --- /dev/null +++ b/packages/expo/ios/ClerkUserButtonViewManager.swift @@ -0,0 +1,13 @@ +import React + +@objc(ClerkUserButtonViewManager) +class ClerkUserButtonViewManager: RCTViewManager { + + override static func requiresMainQueueSetup() -> Bool { + return true + } + + override func view() -> UIView! { + return ClerkUserButtonNativeView() + } +} diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index 1d66bb72ed4..3db2650f811 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -60,6 +60,7 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { // Clerk.configure() is a no-op on subsequent calls, so we use refreshClient(). if Self.shouldRefreshConfiguredClient(for: bearerToken) { _ = try? await Clerk.shared.refreshClient() + await Self.waitForLoadedSession() return } @@ -107,7 +108,7 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { // Wait for Clerk to finish loading (cached data + API refresh). // The static configure() fires off async refreshes; poll until loaded. for _ in 0.. Void + ) -> UIViewController? { + makeHostingController( + rootView: ClerkInlineUserButtonWrapperView( + lightTheme: lightTheme, + darkTheme: darkTheme, + onEvent: onEvent + ) + ) + } + @MainActor public func getSession() async -> [String: Any]? { guard Self.clerkConfigured, let session = Clerk.shared.session else { @@ -392,6 +405,41 @@ private struct ExpoKeychain { } } +// MARK: - Inline User Button Wrapper (for embedded rendering) + +struct ClerkInlineUserButtonWrapperView: View { + let lightTheme: ClerkTheme? + let darkTheme: ClerkTheme? + let onEvent: (String, [String: Any]) -> Void + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let view = UserButton() + .environment(Clerk.shared) + let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme + let themedView = Group { + if let theme { + view.environment(\.clerkTheme, theme) + } else { + view + } + } + themedView + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .task { + for await event in Clerk.shared.auth.events { + switch event { + case .signedOut(let session): + onEvent("signedOut", ["sessionId": session.id]) + default: + break + } + } + } + } +} + // MARK: - Inline Auth View Wrapper (for embedded rendering) struct ClerkInlineAuthWrapperView: View { diff --git a/packages/expo/src/hooks/useNativeAuthEvents.ts b/packages/expo/src/hooks/useNativeAuthEvents.ts index 7a18e4df16f..d326e360501 100644 --- a/packages/expo/src/hooks/useNativeAuthEvents.ts +++ b/packages/expo/src/hooks/useNativeAuthEvents.ts @@ -29,9 +29,8 @@ export interface UseNativeAuthEventsReturn { * * This provides reactive updates when the user signs in or out via native UI. * Events are emitted by the native module when: - * - User completes sign-in (signInCompleted event from clerk-ios/clerk-android) - * - User completes sign-up (signUpCompleted event from clerk-ios/clerk-android) - * - User signs out (signedOut event from clerk-ios/clerk-android) + * - a native AuthView creates or activates a session + * - a native UserProfileView or UserButton signs out * * @example * ```tsx @@ -63,7 +62,7 @@ export function useNativeAuthEvents(): UseNativeAuthEventsReturn { let subscription: { remove: () => void } | null = null; try { - const eventEmitter = new NativeEventEmitter(ClerkExpo as any); + const eventEmitter = new NativeEventEmitter(ClerkExpo); subscription = eventEmitter.addListener('onAuthStateChange', (event: NativeAuthStateEvent) => { if (__DEV__) { diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx index 1216c99f7ca..658c720f65e 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo/src/native/AuthView.tsx @@ -1,60 +1,10 @@ -import { ClerkRuntimeError } from '@clerk/shared/error'; -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import { Text, View } from 'react-native'; -import { CLERK_CLIENT_JWT_KEY } from '../constants'; -import { getClerkInstance } from '../provider/singleton'; import NativeClerkAuthView from '../specs/NativeClerkAuthView'; -import { tokenCache } from '../token-cache'; -import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; +import { isNativeSupported } from '../utils/native-module'; import type { AuthViewProps } from './AuthView.types'; -export async function syncNativeSession(sessionId: string): Promise { - // Copy the native client's bearer token to the JS SDK's token cache - if (ClerkExpo?.getClientToken) { - const nativeClientToken = await ClerkExpo.getClientToken(); - if (__DEV__) { - console.log( - '[syncNativeSession] getClientToken:', - nativeClientToken ? `${nativeClientToken.slice(0, 20)}...` : 'null', - ); - } - if (nativeClientToken) { - await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); - } - } - - const clerkInstance = getClerkInstance(); - if (!clerkInstance) { - throw new ClerkRuntimeError( - 'Clerk instance is not available. Ensure is mounted before using .', - { code: 'expo_auth_view_clerk_instance_not_available' }, - ); - } - - // Reload resources using the native client's token - const clerkRecord = clerkInstance as unknown as Record; - if (typeof clerkRecord.__internal_reloadInitialResources === 'function') { - if (__DEV__) { - console.log('[syncNativeSession] reloading initial resources...'); - } - await (clerkRecord.__internal_reloadInitialResources as () => Promise)(); - if (__DEV__) { - console.log('[syncNativeSession] reload complete'); - } - } - - if (typeof clerkInstance.setActive === 'function') { - if (__DEV__) { - console.log('[syncNativeSession] calling setActive with session:', sessionId); - } - await clerkInstance.setActive({ session: sessionId }); - if (__DEV__) { - console.log('[syncNativeSession] setActive complete'); - } - } -} - /** * A pre-built native authentication component that handles sign-in and sign-up flows. * @@ -85,53 +35,13 @@ export async function syncNativeSession(sessionId: string): Promise { * @see {@link https://clerk.com/docs/components/authentication/sign-in} Clerk Sign-In Documentation */ export function AuthView({ mode = 'signInOrUp', isDismissable = false, onDismiss }: AuthViewProps) { - const authCompletedRef = useRef(false); - - const syncSession = useCallback(async (sessionId: string) => { - if (authCompletedRef.current) { - return; - } - - if (__DEV__) { - console.log('[AuthView] syncSession called with sessionId:', sessionId); - } - - try { - await syncNativeSession(sessionId); - authCompletedRef.current = true; - if (__DEV__) { - console.log('[AuthView] syncSession succeeded'); - } - } catch (err) { - if (__DEV__) { - console.error('[AuthView] Failed to sync session:', err); - } - } - }, []); - const handleAuthEvent = useCallback( - async (event: { nativeEvent: { type: string; data: string } }) => { - const { type, data: rawData } = event.nativeEvent; - if (__DEV__) { - console.log('[AuthView] onAuthEvent:', type, rawData); - } - const data: Record = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; - - if (type === 'dismissed') { + (event: { nativeEvent: { type: string } }) => { + if (event.nativeEvent.type === 'dismissed') { onDismiss?.(); - return; - } - - if (type === 'signInCompleted' || type === 'signUpCompleted') { - const sessionId = data?.sessionId; - if (sessionId) { - await syncSession(sessionId); - } else if (__DEV__) { - console.warn('[AuthView] Auth event received but no sessionId in data:', data); - } } }, - [onDismiss, syncSession], + [onDismiss], ); if (!isNativeSupported || !NativeClerkAuthView) { diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx index f16e1a23797..96bf4e37da6 100644 --- a/packages/expo/src/native/UserButton.tsx +++ b/packages/expo/src/native/UserButton.tsx @@ -1,191 +1,38 @@ -import { useUser } from '@clerk/react'; -import { useEffect, useState } from 'react'; -import type { GestureResponderEvent, StyleProp, ViewStyle } from 'react-native'; -import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { StyleSheet } from 'react-native'; -import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; - -// Raw result from native module (may vary by platform) -interface NativeSessionResult { - sessionId?: string; - session?: { id: string }; - user?: { id: string; firstName?: string; lastName?: string; imageUrl?: string; primaryEmailAddress?: string }; -} - -function getInitials(user: { firstName?: string; lastName?: string } | null): string { - if (user?.firstName) { - const first = user.firstName.charAt(0).toUpperCase(); - const last = user.lastName?.charAt(0).toUpperCase() || ''; - return first + last; - } - return 'U'; -} - -interface NativeUser { - id: string; - firstName?: string; - lastName?: string; - imageUrl?: string; - primaryEmailAddress?: string; -} - -/** - * Props for the UserButton component. - */ -export interface UserButtonProps { - /** - * Called when the button is pressed. - * - * Use this to present your own `UserProfileView` sheet, modal, or route. - */ - onPress?: (event: GestureResponderEvent) => void; - - /** - * Style applied to the button container. - */ - style?: StyleProp; -} +import NativeClerkUserButtonView from '../specs/NativeClerkUserButtonView'; +import { isNativeSupported } from '../utils/native-module'; /** * A pre-built button component that displays the user's avatar. * - * `UserButton` renders a circular button showing the user's profile image (or initials if - * no image is available). Use `onPress` to present your own `UserProfileView` sheet, - * modal, or route. + * `UserButton` renders the platform-native Clerk user button. Tapping it opens + * the native user profile surface, matching Clerk's iOS and Android SDKs. * - * @example Basic usage in a header + * @example * ```tsx * import { UserButton } from '@clerk/expo/native'; * - * export default function Header() { - * return ( - * - * My App - * - * - * ); - * } - * ``` - * - * @example Presenting the profile in an app-owned modal - * ```tsx - * import { UserButton, UserProfileView } from '@clerk/expo/native'; - * - * export default function Header() { - * const [isProfileOpen, setIsProfileOpen] = useState(false); - * - * return ( - * <> - * setIsProfileOpen(true)} - * style={{ width: 40, height: 40 }} - * /> - * - * - * - * - * ); + * export default function Home() { + * return ; * } * ``` * * @see {@link UserProfileView} The profile view to render in your own presentation surface * @see {@link https://clerk.com/docs/components/user/user-button} Clerk UserButton Documentation */ -export function UserButton({ onPress, style }: UserButtonProps) { - const [nativeUser, setNativeUser] = useState(null); - const { user: clerkUser } = useUser(); - - // Fetch native user data on mount and when clerk user changes - useEffect(() => { - const fetchUser = async () => { - if (!isNativeSupported || !ClerkExpo?.getSession) { - return; - } - - try { - const result = (await ClerkExpo.getSession()) as NativeSessionResult | null; - const hasSession = !!(result?.sessionId || result?.session?.id); - if (hasSession && result?.user) { - setNativeUser(result.user); - } else { - // Clear local state if no native session - setNativeUser(null); - } - } catch (err) { - if (__DEV__) { - console.error('[UserButton] Error fetching user:', err); - } - } - }; - - void fetchUser(); - }, [clerkUser?.id]); // Re-fetch when clerk user changes (including sign-out) - - // Derive the user to display - prefer native data, fall back to clerk-react data - const user: NativeUser | null = - nativeUser ?? - (clerkUser - ? { - id: clerkUser.id, - firstName: clerkUser.firstName ?? undefined, - lastName: clerkUser.lastName ?? undefined, - imageUrl: clerkUser.imageUrl ?? undefined, - primaryEmailAddress: clerkUser.primaryEmailAddress?.emailAddress, - } - : null); - - // Show fallback when native modules aren't available - if (!isNativeSupported || !ClerkExpo) { - return ( - - ? - - ); +export function UserButton() { + if (!isNativeSupported || !NativeClerkUserButtonView) { + return null; } - return ( - - {user?.imageUrl ? ( - - ) : ( - - {getInitials(user)} - - )} - - ); + return ; } const styles = StyleSheet.create({ - button: { - width: 40, - height: 40, - borderRadius: 9999, - overflow: 'hidden', - }, - avatar: { - flex: 1, - backgroundColor: '#6366f1', - justifyContent: 'center', - alignItems: 'center', - }, - avatarImage: { - width: '100%', - height: '100%', - }, - avatarText: { - color: 'white', - fontSize: 14, - fontWeight: '600', - }, - text: { - fontSize: 14, - color: '#666', + // React Native/Yoga does not infer the intrinsic size of this native host view. + host: { + width: 36, + height: 36, }, }); diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo/src/native/UserProfileView.tsx index 2d28b901a69..8e26d48846b 100644 --- a/packages/expo/src/native/UserProfileView.tsx +++ b/packages/expo/src/native/UserProfileView.tsx @@ -1,10 +1,9 @@ -import { useClerk } from '@clerk/react'; -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import type { StyleProp, ViewStyle } from 'react-native'; import { StyleSheet, Text, View } from 'react-native'; import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView'; -import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; +import { isNativeSupported } from '../utils/native-module'; /** * Props for the UserProfileView component. @@ -62,41 +61,13 @@ export interface UserProfileViewProps { * @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation */ export function UserProfileView({ isDismissable = false, style, onDismiss }: UserProfileViewProps) { - const clerk = useClerk(); - const signOutTriggered = useRef(false); - const handleProfileEvent = useCallback( - async (event: { nativeEvent: { type: string; data: string } }) => { - const { type } = event.nativeEvent; - - if (type === 'dismissed') { + (event: { nativeEvent: { type: string } }) => { + if (event.nativeEvent.type === 'dismissed') { onDismiss?.(); - return; - } - - if (type === 'signedOut' && !signOutTriggered.current) { - signOutTriggered.current = true; - - try { - await ClerkExpo?.signOut(); - } catch (e) { - if (__DEV__) { - console.warn('[UserProfileView] Native signOut error (may already be signed out):', e); - } - } - - if (clerk?.signOut) { - try { - await clerk.signOut(); - } catch (err) { - if (__DEV__) { - console.warn('[UserProfileView] JS SDK sign out error:', err); - } - } - } } }, - [clerk, onDismiss], + [onDismiss], ); if (!isNativeSupported || !NativeClerkUserProfileView) { diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts index 8733e8f3189..b59a8eeb106 100644 --- a/packages/expo/src/native/index.ts +++ b/packages/expo/src/native/index.ts @@ -23,7 +23,7 @@ * * - {@link AuthView} - Authentication flow (sign-in/sign-up), renders inline * - {@link UserProfileView} - User profile and account management, renders inline - * - {@link UserButton} - Avatar button for opening your own profile surface + * - {@link UserButton} - Avatar button that opens the native user profile * * @module @clerk/expo/native */ @@ -31,6 +31,5 @@ export { AuthView } from './AuthView'; export type { AuthViewProps, AuthViewMode } from './AuthView.types'; export { UserButton } from './UserButton'; -export type { UserButtonProps } from './UserButton'; export { UserProfileView } from './UserProfileView'; export type { UserProfileViewProps } from './UserProfileView'; diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index d096cea4724..894d762bf6a 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -53,6 +53,52 @@ const SDK_METADATA = { version: PACKAGE_VERSION, }; +type NativeSessionResult = { + sessionId?: string; + session?: { id?: string }; + user?: { id?: string }; +} | null; + +function getNativeSessionId(nativeSession: NativeSessionResult): string | null { + return nativeSession?.sessionId ?? nativeSession?.session?.id ?? null; +} + +async function saveNativeClientTokenToJs(tokenCache: TokenCache | undefined): Promise { + const ClerkExpo = NativeClerkModule; + if (!ClerkExpo?.getClientToken) { + return; + } + + const nativeClientToken = await ClerkExpo.getClientToken(); + if (nativeClientToken) { + const effectiveTokenCache = tokenCache ?? defaultTokenCache; + await effectiveTokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); + } +} + +async function syncNativeSessionToJs({ + sessionId, + clerkInstance, + tokenCache, +}: { + sessionId: string; + clerkInstance: any; + tokenCache: TokenCache | undefined; +}) { + await saveNativeClientTokenToJs(tokenCache); + + const sessionInClient = clerkInstance.client?.sessions?.some((s: { id: string }) => s.id === sessionId); + if (!sessionInClient) { + if (typeof clerkInstance.__internal_reloadInitialResources === 'function') { + await clerkInstance.__internal_reloadInitialResources(); + } + } + + if (typeof clerkInstance.setActive === 'function') { + await clerkInstance.setActive({ session: sessionId }); + } +} + /** * Syncs JS SDK auth state to the native Clerk SDK. * @@ -72,6 +118,7 @@ function NativeSessionSync({ }) { const { isSignedIn, isLoaded } = useAuth(); const hasSyncedRef = useRef(false); + const wasSignedInRef = useRef(false); // Use the provided tokenCache, falling back to the default SecureStore cache const effectiveTokenCache = tokenCache ?? defaultTokenCache; @@ -79,25 +126,38 @@ function NativeSessionSync({ if (!isSignedIn) { hasSyncedRef.current = false; - // Only call native signOut when Clerk has fully loaded and confirmed - // the user is actually signed out. Without this check, a JS reload - // (e.g. pressing R in Expo) triggers signOut during the loading phase - // (when isSignedIn is undefined), which revokes the session server-side - // and clears all keychain items, forcing the user to log in again. - if (isLoaded) { - const ClerkExpo = NativeClerkModule; - if (ClerkExpo?.signOut) { - void ClerkExpo.signOut().catch((error: unknown) => { - if (__DEV__) { - console.warn('[NativeSessionSync] Failed to clear native session:', error); - } - }); - } + // Only propagate a JS sign-out after this provider has observed a signed-in + // JS state. On cold start, JS may briefly be signed out while native still + // has the persisted session that ClerkProvider is about to activate. + if (isLoaded && wasSignedInRef.current) { + wasSignedInRef.current = false; + + const clearNativeSession = async () => { + const ClerkExpo = NativeClerkModule; + if (!ClerkExpo?.signOut || !ClerkExpo?.getSession) { + return; + } + + const nativeSession = (await ClerkExpo.getSession()) as NativeSessionResult; + if (getNativeSessionId(nativeSession)) { + await ClerkExpo.signOut(); + } + }; + + void clearNativeSession().catch((error: unknown) => { + if (__DEV__) { + console.warn('[NativeSessionSync] Failed to clear native session:', error); + } + }); + } else if (isLoaded) { + wasSignedInRef.current = false; } return; } + wasSignedInRef.current = true; + if (hasSyncedRef.current) { return; } @@ -109,14 +169,12 @@ function NativeSessionSync({ return; } - // Check if native already has a session (e.g. auth via AuthView or initial load) - const nativeSession = (await ClerkExpo.getSession()) as { - sessionId?: string; - session?: { id: string }; - } | null; - const hasNativeSession = !!(nativeSession?.sessionId || nativeSession?.session?.id); + // Check if native already has a hydrated session (e.g. auth via AuthView or initial load) + const nativeSession = (await ClerkExpo.getSession()) as NativeSessionResult; + const hasNativeSession = !!getNativeSessionId(nativeSession); + const hasNativeUser = !!nativeSession?.user?.id; - if (hasNativeSession) { + if (hasNativeSession && hasNativeUser) { hasSyncedRef.current = true; return; } @@ -140,51 +198,28 @@ function NativeSessionSync({ return null; } -export function ClerkProvider(props: ClerkProviderProps): JSX.Element { - const { - children, - tokenCache, - publishableKey, - proxyUrl, - domain, - __experimental_passkeys, - experimental, - __experimental_resourceCache, - ...rest - } = props; - const pk = publishableKey; - - // Track pending native session to sync after clerk loads - const pendingNativeSessionRef = useRef(null); +function useNativeSessionBootstrap({ + publishableKey, + tokenCache, + clerkInstance, +}: { + publishableKey: string; + tokenCache: TokenCache | undefined; + clerkInstance: any; +}) { const initStartedRef = useRef(false); const sessionSyncedRef = useRef(false); - // Reset refs when publishable key changes (hot-swap support) + const isMountedRef = useRef(true); + useEffect(() => { - pendingNativeSessionRef.current = null; initStartedRef.current = false; sessionSyncedRef.current = false; - }, [pk]); + }, [publishableKey]); - // Get the Clerk instance for syncing - const clerkInstance = isNative() - ? getClerkInstance({ - publishableKey: pk, - tokenCache, - proxyUrl, - domain, - __experimental_passkeys, - __experimental_resourceCache, - }) - : null; - - // Track whether the component is still mounted - const isMountedRef = useRef(true); - - // Configure native Clerk SDK and set up session sync callback useEffect(() => { isMountedRef.current = true; - if ((Platform.OS === 'ios' || Platform.OS === 'android') && pk && !initStartedRef.current) { + if ((Platform.OS === 'ios' || Platform.OS === 'android') && publishableKey && !initStartedRef.current) { initStartedRef.current = true; const configureNativeClerk = async () => { @@ -192,8 +227,6 @@ export function ClerkProvider(props: ClerkProviderProps(props: ClerkProviderProps(props: ClerkProviderProps(props: ClerkProviderProps => { return new Promise(resolve => { - if (clerkAny.loaded) { + if (clerkInstance.loaded) { resolve(); - } else if (typeof clerkAny.addOnLoaded === 'function') { - clerkAny.addOnLoaded(() => resolve()); + } else if (typeof clerkInstance.addOnLoaded === 'function') { + clerkInstance.addOnLoaded(() => resolve()); } else { if (__DEV__) { console.warn('[ClerkProvider] Clerk instance has no loaded property or addOnLoaded method'); @@ -267,26 +287,13 @@ export function ClerkProvider(props: ClerkProviderProps s.id === pendingSession, - ); - if (!sessionInClient && typeof clerkAny.__internal_reloadInitialResources === 'function') { - await clerkAny.__internal_reloadInitialResources(); - } - - try { - await clerkInstance.setActive({ session: pendingSession }); - } catch (err) { - if (__DEV__) { - console.error(`[ClerkProvider] Failed to sync native session:`, err); - } - } + await syncNativeSessionToJs({ + sessionId, + clerkInstance, + tokenCache, + }); } } } @@ -313,7 +320,41 @@ export function ClerkProvider(props: ClerkProviderProps { isMountedRef.current = false; }; - }, [pk, clerkInstance]); + }, [publishableKey, tokenCache, clerkInstance]); + + return isMountedRef; +} + +export function ClerkProvider(props: ClerkProviderProps): JSX.Element { + const { + children, + tokenCache, + publishableKey, + proxyUrl, + domain, + __experimental_passkeys, + experimental, + __experimental_resourceCache, + ...rest + } = props; + const pk = publishableKey; + + const clerkInstance = isNative() + ? getClerkInstance({ + publishableKey: pk, + tokenCache, + proxyUrl, + domain, + __experimental_passkeys, + __experimental_resourceCache, + }) + : null; + + const isMountedRef = useNativeSessionBootstrap({ + publishableKey: pk, + tokenCache, + clerkInstance, + }); // Listen for native auth state changes and sync to JS SDK const { nativeAuthState } = useNativeAuthEvents(); @@ -326,40 +367,24 @@ export function ClerkProvider(props: ClerkProviderProps { try { if (nativeAuthState.type === 'signedIn' && nativeAuthState.sessionId && clerkInstance.setActive) { - // Copy the native client's bearer token to the JS SDK's token cache - // so API requests use the native client (which has the session). - const ClerkExpo = NativeClerkModule; - if (ClerkExpo?.getClientToken) { - const nativeClientToken = await ClerkExpo.getClientToken(); - if (nativeClientToken) { - const effectiveTokenCache = tokenCache ?? defaultTokenCache; - await effectiveTokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); - } - } - - // Ensure the session exists in the client before calling setActive - const sessionInClient = clerkInstance.client?.sessions?.some( - (s: { id: string }) => s.id === nativeAuthState.sessionId, - ); - if (!sessionInClient) { - const clerkAny = clerkInstance as any; - if (typeof clerkAny.__internal_reloadInitialResources === 'function') { - await clerkAny.__internal_reloadInitialResources(); - } - if (!isMountedRef.current) { - return; - } - } - if (!isMountedRef.current) { return; } - await clerkInstance.setActive({ session: nativeAuthState.sessionId }); - } else if (nativeAuthState.type === 'signedOut' && clerkInstance.signOut) { + await syncNativeSessionToJs({ + sessionId: nativeAuthState.sessionId, + clerkInstance, + tokenCache, + }); + } else if (nativeAuthState.type === 'signedOut') { if (!isMountedRef.current) { return; } - await clerkInstance.signOut(); + const clerkAny = clerkInstance as any; + if (typeof clerkAny.handleUnauthenticated === 'function') { + await clerkAny.handleUnauthenticated(); + } else if (clerkInstance.signOut) { + await clerkInstance.signOut(); + } } } catch (error) { if (__DEV__) { @@ -369,7 +394,7 @@ export function ClerkProvider(props: ClerkProviderProps; +type AuthEvent = Readonly<{ type: string }>; interface NativeProps extends ViewProps { mode?: string; diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index de483c672da..46c06ae6dac 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -3,9 +3,15 @@ import { TurboModuleRegistry } from 'react-native'; import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypesNamespace'; export interface Spec extends TurboModule { + // Required by NativeEventEmitter for internal native auth-state events. + // This is not part of the public @clerk/expo API. + addListener(eventName: string): void; configure(publishableKey: string, bearerToken: string | null): Promise; getSession(): Promise; getClientToken(): Promise; + // Required by NativeEventEmitter for internal native auth-state events. + // This is not part of the public @clerk/expo API. + removeListeners(count: number): void; signOut(): Promise; } diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.ts b/packages/expo/src/specs/NativeClerkUserButtonView.ts new file mode 100644 index 00000000000..00ba363a2fc --- /dev/null +++ b/packages/expo/src/specs/NativeClerkUserButtonView.ts @@ -0,0 +1,9 @@ +/* eslint-disable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */ +// These deep imports from react-native internals are required by codegen. +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; +import type { HostComponent, ViewProps } from 'react-native'; +/* eslint-enable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */ + +type NativeProps = ViewProps; + +export default codegenNativeComponent('ClerkUserButtonView') as HostComponent; diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo/src/specs/NativeClerkUserProfileView.ts index a6096769738..1b18bd6e167 100644 --- a/packages/expo/src/specs/NativeClerkUserProfileView.ts +++ b/packages/expo/src/specs/NativeClerkUserProfileView.ts @@ -5,7 +5,7 @@ import type { HostComponent, ViewProps } from 'react-native'; import type { BubblingEventHandler } from 'react-native/Libraries/Types/CodegenTypes'; /* eslint-enable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */ -type ProfileEvent = Readonly<{ type: string; data: string }>; +type ProfileEvent = Readonly<{ type: string }>; interface NativeProps extends ViewProps { isDismissable?: boolean; From 96104bb2931ea063b155f6342ae6e6f89f24360a Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 28 May 2026 17:58:52 -0400 Subject: [PATCH 08/14] fix(expo): mark prebuilt changeset as minor --- .changeset/remove-expo-present-auth.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/remove-expo-present-auth.md b/.changeset/remove-expo-present-auth.md index 46172be3bea..8838b2688e6 100644 --- a/.changeset/remove-expo-present-auth.md +++ b/.changeset/remove-expo-present-auth.md @@ -1,7 +1,9 @@ --- -'@clerk/expo': major +'@clerk/expo': minor --- +Update the beta Expo prebuilt component APIs to align more closely with the native Clerk SDKs. + Remove unused Expo prebuilt-view bridge code: the native `presentAuth` and `presentUserProfile` bridges, Android presentation activities/factory paths, the user-profile modal hook, and stale `Inline*` source files now superseded by app-presented `AuthView` and `UserProfileView`. Align `UserButton` with the native Clerk SDKs by wrapping the platform-native user button, letting it present the user profile from the button itself. From 9ea8179fb4fe97a7cbdc4502119d1080f5f2aa45 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 28 May 2026 18:02:44 -0400 Subject: [PATCH 09/14] fix(expo): remove redundant setActive guard --- packages/expo/src/provider/ClerkProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 894d762bf6a..2ba18e2193d 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -366,7 +366,7 @@ export function ClerkProvider(props: ClerkProviderProps { try { - if (nativeAuthState.type === 'signedIn' && nativeAuthState.sessionId && clerkInstance.setActive) { + if (nativeAuthState.type === 'signedIn' && nativeAuthState.sessionId) { if (!isMountedRef.current) { return; } From 7dbc75635e89ce4816c7e41dbc0aa0ea46e1458c Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Fri, 29 May 2026 10:59:06 -0400 Subject: [PATCH 10/14] fix(expo): address native prebuilt review comments --- .../src/main/java/expo/modules/clerk/ClerkExpoModule.kt | 6 +----- .../java/expo/modules/clerk/ClerkUserButtonExpoView.kt | 8 +++++--- packages/expo/src/specs/NativeClerkUserButtonView.ts | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index eea8c1043f9..8ce356c6fca 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -47,11 +47,7 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : val event = Arguments.createMap().apply { putString("type", type) - if (sessionId == null) { - putNull("sessionId") - } else { - putString("sessionId", sessionId) - } + putString("sessionId", sessionId) } sharedReactContext diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonExpoView.kt index f0f5a79a20a..57ace13533c 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonExpoView.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonExpoView.kt @@ -6,6 +6,7 @@ import android.widget.FrameLayout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Recomposer @@ -89,7 +90,7 @@ class ClerkUserButtonNativeView(context: Context) : FrameLayout(context) { } } - val content = @androidx.compose.runtime.Composable { + val userButtonContent: @Composable () -> Unit = { MaterialTheme { Box( modifier = Modifier.fillMaxSize(), @@ -101,15 +102,16 @@ class ClerkUserButtonNativeView(context: Context) : FrameLayout(context) { } if (activity != null) { + // Compose content embedded in React Native needs Activity owners supplied explicitly. CompositionLocalProvider( LocalViewModelStoreOwner provides activity, LocalLifecycleOwner provides activity, LocalSavedStateRegistryOwner provides activity, ) { - content() + userButtonContent() } } else { - content() + userButtonContent() } } } diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.ts b/packages/expo/src/specs/NativeClerkUserButtonView.ts index 00ba363a2fc..e43ed89164f 100644 --- a/packages/expo/src/specs/NativeClerkUserButtonView.ts +++ b/packages/expo/src/specs/NativeClerkUserButtonView.ts @@ -4,6 +4,6 @@ import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNati import type { HostComponent, ViewProps } from 'react-native'; /* eslint-enable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */ -type NativeProps = ViewProps; +interface NativeProps extends ViewProps {} export default codegenNativeComponent('ClerkUserButtonView') as HostComponent; From fba63981f34ee9bf15eb5542086c6a617728ed9a Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Fri, 29 May 2026 11:07:51 -0400 Subject: [PATCH 11/14] fix(expo): allow codegen-only user button props --- packages/expo/src/specs/NativeClerkUserButtonView.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.ts b/packages/expo/src/specs/NativeClerkUserButtonView.ts index e43ed89164f..e5541213487 100644 --- a/packages/expo/src/specs/NativeClerkUserButtonView.ts +++ b/packages/expo/src/specs/NativeClerkUserButtonView.ts @@ -4,6 +4,8 @@ import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNati import type { HostComponent, ViewProps } from 'react-native'; /* eslint-enable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */ +// Codegen requires an interface declaration here; a type alias fails Android codegen. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type interface NativeProps extends ViewProps {} export default codegenNativeComponent('ClerkUserButtonView') as HostComponent; From 4c4097fbc51a1d9b67b4c197f26b6a3eaa8d01d9 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:44:07 -0400 Subject: [PATCH 12/14] refactor(expo): clean up native prebuilt views --- packages/expo/ios/ClerkAuthNativeView.swift | 77 +++++ packages/expo/ios/ClerkExpo.podspec | 4 + packages/expo/ios/ClerkExpoModule.swift | 305 +----------------- packages/expo/ios/ClerkNativeEvent.swift | 18 ++ .../ios/ClerkNativeHostingCoordinator.swift | 61 ++++ .../expo/ios/ClerkUserButtonNativeView.swift | 42 +++ .../expo/ios/ClerkUserProfileNativeView.swift | 66 ++++ packages/expo/ios/ClerkViewFactory.swift | 28 +- 8 files changed, 287 insertions(+), 314 deletions(-) create mode 100644 packages/expo/ios/ClerkAuthNativeView.swift create mode 100644 packages/expo/ios/ClerkNativeEvent.swift create mode 100644 packages/expo/ios/ClerkNativeHostingCoordinator.swift create mode 100644 packages/expo/ios/ClerkUserButtonNativeView.swift create mode 100644 packages/expo/ios/ClerkUserProfileNativeView.swift diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo/ios/ClerkAuthNativeView.swift new file mode 100644 index 00000000000..e5ea7b0e193 --- /dev/null +++ b/packages/expo/ios/ClerkAuthNativeView.swift @@ -0,0 +1,77 @@ +import React +import UIKit + +public class ClerkAuthNativeView: UIView { + private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) + private var currentMode: String = "signInOrUp" + private var currentDismissable: Bool = false + private var hasInitialized: Bool = false + private var didCompleteAuthentication: Bool = false + private var dismissalEventSent: Bool = false + + @objc var onAuthEvent: RCTBubblingEventBlock? + + @objc var mode: NSString? { + didSet { + let newMode = (mode as String?) ?? "signInOrUp" + guard newMode != currentMode else { return } + currentMode = newMode + if hasInitialized { updateView() } + } + } + + @objc var isDismissable: NSNumber? { + didSet { + let newDismissable = isDismissable?.boolValue ?? false + guard newDismissable != currentDismissable else { return } + currentDismissable = newDismissable + if hasInitialized { updateView() } + } + } + + override public init(frame: CGRect) { + super.init(frame: frame) + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func didMoveToWindow() { + super.didMoveToWindow() + if window != nil && !hasInitialized { + hasInitialized = true + updateView() + } else if window == nil && hasInitialized && currentDismissable && !didCompleteAuthentication && !dismissalEventSent { + dismissalEventSent = true + sendAuthEvent(type: .dismissed) + } + } + + private func sendAuthEvent(type: ClerkNativeViewEvent) { + onAuthEvent?(["type": type.rawValue]) + } + + private func updateView() { + guard let factory = clerkViewFactory else { return } + + guard let returnedController = factory.createAuthView( + mode: currentMode, + dismissable: currentDismissable, + onEvent: { [weak self] event, data in + if event.isAuthCompletion { + self?.didCompleteAuthentication = true + let sessionId = data["sessionId"] as? String + ClerkExpoModule.emitAuthStateChange(type: .signedIn, sessionId: sessionId) + } + } + ) else { return } + + hostingCoordinator.attach(returnedController) + } + + override public func layoutSubviews() { + super.layoutSubviews() + hostingCoordinator.layout() + } +} diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec index e511c8cb374..aecca6f33d4 100644 --- a/packages/expo/ios/ClerkExpo.podspec +++ b/packages/expo/ios/ClerkExpo.podspec @@ -39,8 +39,12 @@ Pod::Spec.new do |s| # ClerkViewFactory.swift (with views) is injected into the app target by the config plugin # because it uses `import ClerkKit` which is only available via SPM in the app target. s.source_files = "ClerkExpoModule.swift", "ClerkExpoModule.m", + "ClerkNativeEvent.swift", "ClerkNativeHostingCoordinator.swift", + "ClerkAuthNativeView.swift", "ClerkAuthViewManager.swift", "ClerkAuthViewManager.m", + "ClerkUserProfileNativeView.swift", "ClerkUserProfileViewManager.swift", "ClerkUserProfileViewManager.m", + "ClerkUserButtonNativeView.swift", "ClerkUserButtonViewManager.swift", "ClerkUserButtonViewManager.m" install_modules_dependencies(s) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index 4c9114df97e..539d544f0d4 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -12,9 +12,9 @@ public var clerkViewFactory: ClerkViewFactoryProtocol? // Protocol that the app target implements to provide Clerk views public protocol ClerkViewFactoryProtocol { // Inline rendering — returns UIViewController to preserve SwiftUI lifecycle - func createAuthView(mode: String, dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController? - func createUserProfileView(dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController? - func createUserButton(onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController? + func createAuthView(mode: String, dismissable: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController? + func createUserProfileView(dismissable: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController? + func createUserButton(onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController? // SDK operations func configure(publishableKey: String, bearerToken: String?) async throws @@ -54,10 +54,10 @@ class ClerkExpoModule: RCTEventEmitter { /// Emits an onAuthStateChange event to JS from anywhere in the native layer. /// Used by native views to notify ClerkProvider of auth state changes. - static func emitAuthStateChange(type: String, sessionId: String?) { + static func emitAuthStateChange(type: ClerkNativeAuthStateEvent, sessionId: String?) { guard _hasListeners, let instance = sharedInstance else { return } instance.sendEvent(withName: "onAuthStateChange", body: [ - "type": type, + "type": type.rawValue, "sessionId": sessionId as Any, ]) } @@ -129,298 +129,3 @@ class ClerkExpoModule: RCTEventEmitter { } } } - -// MARK: - Inline View: ClerkUserButtonNativeView - -public class ClerkUserButtonNativeView: UIView { - private var hostingController: UIViewController? - private var hasInitialized: Bool = false - - override init(frame: CGRect) { - super.init(frame: frame) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override public func didMoveToWindow() { - super.didMoveToWindow() - if window != nil && !hasInitialized { - hasInitialized = true - updateView() - } - } - - private func updateView() { - detachHostingController() - - guard let factory = clerkViewFactory else { return } - - guard let returnedController = factory.createUserButton( - onEvent: { eventName, data in - if eventName == "signedOut" { - let sessionId = data["sessionId"] as? String - ClerkExpoModule.emitAuthStateChange(type: "signedOut", sessionId: sessionId) - } - } - ) else { return } - - attachHostingController(returnedController) - } - - private func attachHostingController(_ controller: UIViewController) { - controller.view.frame = bounds - controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - controller.view.backgroundColor = .clear - - if let parentVC = findViewController() { - parentVC.addChild(controller) - addSubview(controller.view) - controller.didMove(toParent: parentVC) - } else { - addSubview(controller.view) - } - - hostingController = controller - } - - private func detachHostingController() { - guard let controller = hostingController else { return } - controller.willMove(toParent: nil) - controller.view.removeFromSuperview() - controller.removeFromParent() - hostingController = nil - } - - private func findViewController() -> UIViewController? { - var responder: UIResponder? = self - while let nextResponder = responder?.next { - if let vc = nextResponder as? UIViewController { - return vc - } - responder = nextResponder - } - return nil - } - - override public func layoutSubviews() { - super.layoutSubviews() - hostingController?.view.frame = bounds - } -} - -// MARK: - Inline View: ClerkAuthNativeView - -public class ClerkAuthNativeView: UIView { - private var hostingController: UIViewController? - private var currentMode: String = "signInOrUp" - private var currentDismissable: Bool = false - private var hasInitialized: Bool = false - private var didCompleteAuthentication: Bool = false - private var dismissalEventSent: Bool = false - - @objc var onAuthEvent: RCTBubblingEventBlock? - - @objc var mode: NSString? { - didSet { - let newMode = (mode as String?) ?? "signInOrUp" - guard newMode != currentMode else { return } - currentMode = newMode - if hasInitialized { updateView() } - } - } - - @objc var isDismissable: NSNumber? { - didSet { - let newDismissable = isDismissable?.boolValue ?? false - guard newDismissable != currentDismissable else { return } - currentDismissable = newDismissable - if hasInitialized { updateView() } - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override public func didMoveToWindow() { - super.didMoveToWindow() - if window != nil && !hasInitialized { - hasInitialized = true - updateView() - } else if window == nil && hasInitialized && currentDismissable && !didCompleteAuthentication && !dismissalEventSent { - dismissalEventSent = true - sendAuthEvent(type: "dismissed") - } - } - - private func sendAuthEvent(type: String) { - onAuthEvent?(["type": type]) - } - - private func updateView() { - detachHostingController() - - guard let factory = clerkViewFactory else { return } - - guard let returnedController = factory.createAuthView( - mode: currentMode, - dismissable: currentDismissable, - onEvent: { [weak self] eventName, data in - let didCompleteAuthentication = eventName == "signInCompleted" || eventName == "signUpCompleted" - - if didCompleteAuthentication { - self?.didCompleteAuthentication = true - let sessionId = data["sessionId"] as? String - ClerkExpoModule.emitAuthStateChange(type: "signedIn", sessionId: sessionId) - } - } - ) else { return } - - attachHostingController(returnedController) - } - - private func attachHostingController(_ controller: UIViewController) { - controller.view.frame = bounds - controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - - if let parentVC = findViewController() { - parentVC.addChild(controller) - addSubview(controller.view) - controller.didMove(toParent: parentVC) - } else { - addSubview(controller.view) - } - - hostingController = controller - } - - private func detachHostingController() { - guard let controller = hostingController else { return } - controller.willMove(toParent: nil) - controller.view.removeFromSuperview() - controller.removeFromParent() - hostingController = nil - } - - private func findViewController() -> UIViewController? { - var responder: UIResponder? = self - while let nextResponder = responder?.next { - if let vc = nextResponder as? UIViewController { - return vc - } - responder = nextResponder - } - return nil - } - - override public func layoutSubviews() { - super.layoutSubviews() - hostingController?.view.frame = bounds - } -} - -// MARK: - Inline View: ClerkUserProfileNativeView - -public class ClerkUserProfileNativeView: UIView { - private var hostingController: UIViewController? - private var currentDismissable: Bool = false - private var hasInitialized: Bool = false - private var didSignOut = false - private var dismissalEventSent = false - - @objc var onProfileEvent: RCTBubblingEventBlock? - - @objc var isDismissable: NSNumber? { - didSet { - currentDismissable = isDismissable?.boolValue ?? false - if hasInitialized { updateView() } - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override public func didMoveToWindow() { - super.didMoveToWindow() - if window != nil && !hasInitialized { - hasInitialized = true - updateView() - } else if window == nil && hasInitialized && currentDismissable && !didSignOut && !dismissalEventSent { - dismissalEventSent = true - sendProfileEvent(type: "dismissed") - } - } - - private func sendProfileEvent(type: String) { - onProfileEvent?(["type": type]) - } - - private func updateView() { - detachHostingController() - - guard let factory = clerkViewFactory else { return } - - guard let returnedController = factory.createUserProfileView( - dismissable: currentDismissable, - onEvent: { [weak self] eventName, data in - if eventName == "signedOut" { - self?.didSignOut = true - let sessionId = data["sessionId"] as? String - ClerkExpoModule.emitAuthStateChange(type: "signedOut", sessionId: sessionId) - } - } - ) else { return } - - attachHostingController(returnedController) - } - - private func attachHostingController(_ controller: UIViewController) { - controller.view.frame = bounds - controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - - if let parentVC = findViewController() { - parentVC.addChild(controller) - addSubview(controller.view) - controller.didMove(toParent: parentVC) - } else { - addSubview(controller.view) - } - - hostingController = controller - } - - private func detachHostingController() { - guard let controller = hostingController else { return } - controller.willMove(toParent: nil) - controller.view.removeFromSuperview() - controller.removeFromParent() - hostingController = nil - } - - private func findViewController() -> UIViewController? { - var responder: UIResponder? = self - while let nextResponder = responder?.next { - if let vc = nextResponder as? UIViewController { - return vc - } - responder = nextResponder - } - return nil - } - - override public func layoutSubviews() { - super.layoutSubviews() - hostingController?.view.frame = bounds - } -} diff --git a/packages/expo/ios/ClerkNativeEvent.swift b/packages/expo/ios/ClerkNativeEvent.swift new file mode 100644 index 00000000000..b29c809a956 --- /dev/null +++ b/packages/expo/ios/ClerkNativeEvent.swift @@ -0,0 +1,18 @@ +/// Events emitted by the native view wrappers to their React Native host views. +public enum ClerkNativeViewEvent: String { + /// Emitted by the Expo host view when app-owned dismissable content leaves the window. + case dismissed + case signedOut + case signInCompleted + case signUpCompleted + + var isAuthCompletion: Bool { + self == .signInCompleted || self == .signUpCompleted + } +} + +/// Auth state changes emitted by the Expo module to ClerkProvider's JS session sync. +enum ClerkNativeAuthStateEvent: String { + case signedIn + case signedOut +} diff --git a/packages/expo/ios/ClerkNativeHostingCoordinator.swift b/packages/expo/ios/ClerkNativeHostingCoordinator.swift new file mode 100644 index 00000000000..c7a843ac3a8 --- /dev/null +++ b/packages/expo/ios/ClerkNativeHostingCoordinator.swift @@ -0,0 +1,61 @@ +import UIKit + +final class ClerkNativeHostingCoordinator { + private weak var containerView: UIView? + private var hostingController: UIViewController? + + init(containerView: UIView) { + self.containerView = containerView + } + + func attach(_ controller: UIViewController, clearBackground: Bool = false) { + detach() + + guard let containerView else { return } + + controller.view.frame = containerView.bounds + controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + if clearBackground { + controller.view.backgroundColor = .clear + } + + if let parentVC = findViewController(from: containerView) { + parentVC.addChild(controller) + containerView.addSubview(controller.view) + controller.didMove(toParent: parentVC) + } else { + containerView.addSubview(controller.view) + } + + hostingController = controller + } + + func detach() { + guard let controller = hostingController else { return } + + if controller.parent != nil { + controller.willMove(toParent: nil) + } + controller.view.removeFromSuperview() + if controller.parent != nil { + controller.removeFromParent() + } + hostingController = nil + } + + func layout() { + guard let containerView else { return } + hostingController?.view.frame = containerView.bounds + } + + private func findViewController(from view: UIView) -> UIViewController? { + var responder: UIResponder? = view + while let nextResponder = responder?.next { + if let vc = nextResponder as? UIViewController { + return vc + } + responder = nextResponder + } + return nil + } +} diff --git a/packages/expo/ios/ClerkUserButtonNativeView.swift b/packages/expo/ios/ClerkUserButtonNativeView.swift new file mode 100644 index 00000000000..76b4561f84c --- /dev/null +++ b/packages/expo/ios/ClerkUserButtonNativeView.swift @@ -0,0 +1,42 @@ +import UIKit + +public class ClerkUserButtonNativeView: UIView { + private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) + private var hasInitialized: Bool = false + + override public init(frame: CGRect) { + super.init(frame: frame) + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func didMoveToWindow() { + super.didMoveToWindow() + if window != nil && !hasInitialized { + hasInitialized = true + updateView() + } + } + + private func updateView() { + guard let factory = clerkViewFactory else { return } + + guard let returnedController = factory.createUserButton( + onEvent: { event, data in + if event == .signedOut { + let sessionId = data["sessionId"] as? String + ClerkExpoModule.emitAuthStateChange(type: .signedOut, sessionId: sessionId) + } + } + ) else { return } + + hostingCoordinator.attach(returnedController, clearBackground: true) + } + + override public func layoutSubviews() { + super.layoutSubviews() + hostingCoordinator.layout() + } +} diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo/ios/ClerkUserProfileNativeView.swift new file mode 100644 index 00000000000..e740e0e6ae1 --- /dev/null +++ b/packages/expo/ios/ClerkUserProfileNativeView.swift @@ -0,0 +1,66 @@ +import React +import UIKit + +public class ClerkUserProfileNativeView: UIView { + private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) + private var currentDismissable: Bool = false + private var hasInitialized: Bool = false + private var didSignOut = false + private var dismissalEventSent = false + + @objc var onProfileEvent: RCTBubblingEventBlock? + + @objc var isDismissable: NSNumber? { + didSet { + let newDismissable = isDismissable?.boolValue ?? false + guard newDismissable != currentDismissable else { return } + currentDismissable = newDismissable + if hasInitialized { updateView() } + } + } + + override public init(frame: CGRect) { + super.init(frame: frame) + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func didMoveToWindow() { + super.didMoveToWindow() + if window != nil && !hasInitialized { + hasInitialized = true + updateView() + } else if window == nil && hasInitialized && currentDismissable && !didSignOut && !dismissalEventSent { + dismissalEventSent = true + sendProfileEvent(type: .dismissed) + } + } + + private func sendProfileEvent(type: ClerkNativeViewEvent) { + onProfileEvent?(["type": type.rawValue]) + } + + private func updateView() { + guard let factory = clerkViewFactory else { return } + + guard let returnedController = factory.createUserProfileView( + dismissable: currentDismissable, + onEvent: { [weak self] event, data in + if event == .signedOut { + self?.didSignOut = true + let sessionId = data["sessionId"] as? String + ClerkExpoModule.emitAuthStateChange(type: .signedOut, sessionId: sessionId) + } + } + ) else { return } + + hostingCoordinator.attach(returnedController) + } + + override public func layoutSubviews() { + super.layoutSubviews() + hostingCoordinator.layout() + } +} diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index 3db2650f811..545a363d8a8 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -155,7 +155,7 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { public func createAuthView( mode: String, dismissable: Bool, - onEvent: @escaping (String, [String: Any]) -> Void + onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { makeHostingController( rootView: ClerkInlineAuthWrapperView( @@ -170,7 +170,7 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { public func createUserProfileView( dismissable: Bool, - onEvent: @escaping (String, [String: Any]) -> Void + onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { makeHostingController( rootView: ClerkInlineProfileWrapperView( @@ -183,7 +183,7 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { } public func createUserButton( - onEvent: @escaping (String, [String: Any]) -> Void + onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { makeHostingController( rootView: ClerkInlineUserButtonWrapperView( @@ -410,7 +410,7 @@ private struct ExpoKeychain { struct ClerkInlineUserButtonWrapperView: View { let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? - let onEvent: (String, [String: Any]) -> Void + let onEvent: (ClerkNativeViewEvent, [String: Any]) -> Void @Environment(\.colorScheme) private var colorScheme @@ -431,7 +431,7 @@ struct ClerkInlineUserButtonWrapperView: View { for await event in Clerk.shared.auth.events { switch event { case .signedOut(let session): - onEvent("signedOut", ["sessionId": session.id]) + onEvent(.signedOut, ["sessionId": session.id]) default: break } @@ -447,7 +447,7 @@ struct ClerkInlineAuthWrapperView: View { let dismissable: Bool let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? - let onEvent: (String, [String: Any]) -> Void + let onEvent: (ClerkNativeViewEvent, [String: Any]) -> Void // Track initial session to detect new sign-ins (same approach as Android) @State private var initialSessionId: String? = Clerk.shared.session?.id @@ -455,10 +455,10 @@ struct ClerkInlineAuthWrapperView: View { @Environment(\.colorScheme) private var colorScheme - private func sendAuthCompleted(sessionId: String, type: String) { + private func sendAuthCompleted(sessionId: String, type: ClerkNativeViewEvent) { guard !eventSent, sessionId != initialSessionId else { return } eventSent = true - onEvent(type, ["sessionId": sessionId, "type": type == "signUpCompleted" ? "signUp" : "signIn"]) + onEvent(type, ["sessionId": sessionId, "type": type == .signUpCompleted ? "signUp" : "signIn"]) } private var themedAuthView: some View { @@ -480,7 +480,7 @@ struct ClerkInlineAuthWrapperView: View { // This is more reliable than auth.events which may not emit for inline AuthView sign-ins. .onChange(of: Clerk.shared.session?.id) { _, newSessionId in guard let sessionId = newSessionId else { return } - sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") + sendAuthCompleted(sessionId: sessionId, type: .signInCompleted) } // Fallback: also listen to auth.events for signUp events and edge cases .task { @@ -489,12 +489,12 @@ struct ClerkInlineAuthWrapperView: View { switch event { case .signInCompleted(let signIn): let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id - if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } + if let sessionId { sendAuthCompleted(sessionId: sessionId, type: .signInCompleted) } case .signUpCompleted(let signUp): let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id - if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signUpCompleted") } + if let sessionId { sendAuthCompleted(sessionId: sessionId, type: .signUpCompleted) } case .sessionChanged(_, let newSession): - if let sessionId = newSession?.id { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } + if let sessionId = newSession?.id { sendAuthCompleted(sessionId: sessionId, type: .signInCompleted) } default: break } @@ -509,7 +509,7 @@ struct ClerkInlineProfileWrapperView: View { let dismissable: Bool let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? - let onEvent: (String, [String: Any]) -> Void + let onEvent: (ClerkNativeViewEvent, [String: Any]) -> Void @Environment(\.colorScheme) private var colorScheme @@ -529,7 +529,7 @@ struct ClerkInlineProfileWrapperView: View { for await event in Clerk.shared.auth.events { switch event { case .signedOut(let session): - onEvent("signedOut", ["sessionId": session.id]) + onEvent(.signedOut, ["sessionId": session.id]) default: break } From c267802c81bff553b7425340085160f263e07478 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:55:34 -0400 Subject: [PATCH 13/14] fix(expo): spell dismissible prop correctly --- .../java/expo/modules/clerk/ClerkAuthExpoView.kt | 4 ++-- .../expo/modules/clerk/ClerkAuthViewManager.kt | 6 +++--- .../modules/clerk/ClerkUserProfileExpoView.kt | 4 ++-- .../modules/clerk/ClerkUserProfileViewManager.kt | 6 +++--- packages/expo/ios/ClerkAuthNativeView.swift | 14 +++++++------- packages/expo/ios/ClerkAuthViewManager.m | 2 +- packages/expo/ios/ClerkExpoModule.swift | 4 ++-- packages/expo/ios/ClerkNativeEvent.swift | 2 +- .../expo/ios/ClerkUserProfileNativeView.swift | 14 +++++++------- packages/expo/ios/ClerkUserProfileViewManager.m | 2 +- packages/expo/ios/ClerkViewFactory.swift | 16 ++++++++-------- packages/expo/src/native/AuthView.tsx | 4 ++-- packages/expo/src/native/AuthView.types.ts | 2 +- packages/expo/src/native/UserProfileView.tsx | 6 +++--- packages/expo/src/specs/NativeClerkAuthView.ts | 2 +- .../expo/src/specs/NativeClerkUserProfileView.ts | 2 +- 16 files changed, 45 insertions(+), 45 deletions(-) diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt index 29b3e19a7d0..ed36333adbc 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt @@ -39,7 +39,7 @@ private fun debugLog(tag: String, message: String) { class ClerkAuthNativeView(context: Context) : FrameLayout(context) { var mode: String = "signInOrUp" - var isDismissable: Boolean = true + var isDismissible: Boolean = true private val activity: ComponentActivity? = findActivity(context).also { // At cold start, ClerkExpoModule.configure() may run before React's @@ -101,7 +101,7 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) { private var authCompletedSent: Boolean = false fun setupView() { - debugLog(TAG, "setupView - mode: $mode, isDismissable: $isDismissable, activity: $activity") + debugLog(TAG, "setupView - mode: $mode, isDismissible: $isDismissible, activity: $activity") composeView.setContent { val session by Clerk.sessionFlow.collectAsStateWithLifecycle() diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt index 9ff989d9ea8..f5a67b24ca3 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt @@ -21,9 +21,9 @@ class ClerkAuthViewManager : SimpleViewManager(), view.setupView() } - @ReactProp(name = "isDismissable") - override fun setIsDismissable(view: ClerkAuthNativeView, isDismissable: Boolean) { - view.isDismissable = isDismissable + @ReactProp(name = "isDismissible") + override fun setIsDismissible(view: ClerkAuthNativeView, isDismissible: Boolean) { + view.isDismissible = isDismissible view.setupView() } diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt index 9ab02767609..3e90a795827 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.launch private const val TAG = "ClerkUserProfileExpoView" class ClerkUserProfileNativeView(context: Context) : FrameLayout(context) { - var isDismissable: Boolean = true + var isDismissible: Boolean = true private val activity = ClerkAuthNativeView.findActivity(context) @@ -68,7 +68,7 @@ class ClerkUserProfileNativeView(context: Context) : FrameLayout(context) { } fun setupView() { - Log.d(TAG, "setupView - isDismissable: $isDismissable") + Log.d(TAG, "setupView - isDismissible: $isDismissible") composeView.setContent { val session by Clerk.sessionFlow.collectAsStateWithLifecycle() diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt index bc5a338271e..312f42d7316 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt @@ -15,9 +15,9 @@ class ClerkUserProfileViewManager : SimpleViewManager Void) -> UIViewController? - func createUserProfileView(dismissable: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController? + func createAuthView(mode: String, dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController? + func createUserProfileView(dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController? func createUserButton(onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController? // SDK operations diff --git a/packages/expo/ios/ClerkNativeEvent.swift b/packages/expo/ios/ClerkNativeEvent.swift index b29c809a956..7a5975ba423 100644 --- a/packages/expo/ios/ClerkNativeEvent.swift +++ b/packages/expo/ios/ClerkNativeEvent.swift @@ -1,6 +1,6 @@ /// Events emitted by the native view wrappers to their React Native host views. public enum ClerkNativeViewEvent: String { - /// Emitted by the Expo host view when app-owned dismissable content leaves the window. + /// Emitted by the Expo host view when app-owned dismissible content leaves the window. case dismissed case signedOut case signInCompleted diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo/ios/ClerkUserProfileNativeView.swift index e740e0e6ae1..3af52ea0955 100644 --- a/packages/expo/ios/ClerkUserProfileNativeView.swift +++ b/packages/expo/ios/ClerkUserProfileNativeView.swift @@ -3,18 +3,18 @@ import UIKit public class ClerkUserProfileNativeView: UIView { private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) - private var currentDismissable: Bool = false + private var currentDismissible: Bool = false private var hasInitialized: Bool = false private var didSignOut = false private var dismissalEventSent = false @objc var onProfileEvent: RCTBubblingEventBlock? - @objc var isDismissable: NSNumber? { + @objc var isDismissible: NSNumber? { didSet { - let newDismissable = isDismissable?.boolValue ?? false - guard newDismissable != currentDismissable else { return } - currentDismissable = newDismissable + let newDismissible = isDismissible?.boolValue ?? false + guard newDismissible != currentDismissible else { return } + currentDismissible = newDismissible if hasInitialized { updateView() } } } @@ -32,7 +32,7 @@ public class ClerkUserProfileNativeView: UIView { if window != nil && !hasInitialized { hasInitialized = true updateView() - } else if window == nil && hasInitialized && currentDismissable && !didSignOut && !dismissalEventSent { + } else if window == nil && hasInitialized && currentDismissible && !didSignOut && !dismissalEventSent { dismissalEventSent = true sendProfileEvent(type: .dismissed) } @@ -46,7 +46,7 @@ public class ClerkUserProfileNativeView: UIView { guard let factory = clerkViewFactory else { return } guard let returnedController = factory.createUserProfileView( - dismissable: currentDismissable, + dismissible: currentDismissible, onEvent: { [weak self] event, data in if event == .signedOut { self?.didSignOut = true diff --git a/packages/expo/ios/ClerkUserProfileViewManager.m b/packages/expo/ios/ClerkUserProfileViewManager.m index 35eaf720ed9..ee06c66a125 100644 --- a/packages/expo/ios/ClerkUserProfileViewManager.m +++ b/packages/expo/ios/ClerkUserProfileViewManager.m @@ -2,7 +2,7 @@ @interface RCT_EXTERN_MODULE(ClerkUserProfileViewManager, RCTViewManager) -RCT_EXPORT_VIEW_PROPERTY(isDismissable, NSNumber) +RCT_EXPORT_VIEW_PROPERTY(isDismissible, NSNumber) RCT_EXPORT_VIEW_PROPERTY(onProfileEvent, RCTBubblingEventBlock) @end diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index 545a363d8a8..b9e344017d6 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -154,13 +154,13 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { public func createAuthView( mode: String, - dismissable: Bool, + dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { makeHostingController( rootView: ClerkInlineAuthWrapperView( mode: Self.authMode(from: mode), - dismissable: dismissable, + dismissible: dismissible, lightTheme: lightTheme, darkTheme: darkTheme, onEvent: onEvent @@ -169,12 +169,12 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { } public func createUserProfileView( - dismissable: Bool, + dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { makeHostingController( rootView: ClerkInlineProfileWrapperView( - dismissable: dismissable, + dismissible: dismissible, lightTheme: lightTheme, darkTheme: darkTheme, onEvent: onEvent @@ -444,7 +444,7 @@ struct ClerkInlineUserButtonWrapperView: View { struct ClerkInlineAuthWrapperView: View { let mode: AuthView.Mode - let dismissable: Bool + let dismissible: Bool let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? let onEvent: (ClerkNativeViewEvent, [String: Any]) -> Void @@ -462,7 +462,7 @@ struct ClerkInlineAuthWrapperView: View { } private var themedAuthView: some View { - let view = AuthView(mode: mode, isDismissable: dismissable) + let view = AuthView(mode: mode, isDismissable: dismissible) .environment(Clerk.shared) let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme return Group { @@ -506,7 +506,7 @@ struct ClerkInlineAuthWrapperView: View { // MARK: - Inline Profile View Wrapper (for embedded rendering) struct ClerkInlineProfileWrapperView: View { - let dismissable: Bool + let dismissible: Bool let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? let onEvent: (ClerkNativeViewEvent, [String: Any]) -> Void @@ -514,7 +514,7 @@ struct ClerkInlineProfileWrapperView: View { @Environment(\.colorScheme) private var colorScheme var body: some View { - let view = UserProfileView(isDismissable: dismissable) + let view = UserProfileView(isDismissable: dismissible) .environment(Clerk.shared) let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme let themedView = Group { diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx index 658c720f65e..14de4d0e534 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo/src/native/AuthView.tsx @@ -34,7 +34,7 @@ import type { AuthViewProps } from './AuthView.types'; * * @see {@link https://clerk.com/docs/components/authentication/sign-in} Clerk Sign-In Documentation */ -export function AuthView({ mode = 'signInOrUp', isDismissable = false, onDismiss }: AuthViewProps) { +export function AuthView({ mode = 'signInOrUp', isDismissible = false, onDismiss }: AuthViewProps) { const handleAuthEvent = useCallback( (event: { nativeEvent: { type: string } }) => { if (event.nativeEvent.type === 'dismissed') { @@ -60,7 +60,7 @@ export function AuthView({ mode = 'signInOrUp', isDismissable = false, onDismiss ); diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts index e795c44ed02..a4886855072 100644 --- a/packages/expo/src/native/AuthView.types.ts +++ b/packages/expo/src/native/AuthView.types.ts @@ -36,7 +36,7 @@ export interface AuthViewProps { * * @default false */ - isDismissable?: boolean; + isDismissible?: boolean; /** * Called when the user dismisses the native authentication view. diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo/src/native/UserProfileView.tsx index 8e26d48846b..276ebf4a5c5 100644 --- a/packages/expo/src/native/UserProfileView.tsx +++ b/packages/expo/src/native/UserProfileView.tsx @@ -17,7 +17,7 @@ export interface UserProfileViewProps { * * @default false */ - isDismissable?: boolean; + isDismissible?: boolean; /** * Style applied to the container view. @@ -60,7 +60,7 @@ export interface UserProfileViewProps { * * @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation */ -export function UserProfileView({ isDismissable = false, style, onDismiss }: UserProfileViewProps) { +export function UserProfileView({ isDismissible = false, style, onDismiss }: UserProfileViewProps) { const handleProfileEvent = useCallback( (event: { nativeEvent: { type: string } }) => { if (event.nativeEvent.type === 'dismissed') { @@ -85,7 +85,7 @@ export function UserProfileView({ isDismissable = false, style, onDismiss }: Use return ( ); diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo/src/specs/NativeClerkAuthView.ts index 0e53d76e2dc..321734c692b 100644 --- a/packages/expo/src/specs/NativeClerkAuthView.ts +++ b/packages/expo/src/specs/NativeClerkAuthView.ts @@ -9,7 +9,7 @@ type AuthEvent = Readonly<{ type: string }>; interface NativeProps extends ViewProps { mode?: string; - isDismissable?: boolean; + isDismissible?: boolean; onAuthEvent?: BubblingEventHandler; } diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo/src/specs/NativeClerkUserProfileView.ts index 1b18bd6e167..289dabf4d70 100644 --- a/packages/expo/src/specs/NativeClerkUserProfileView.ts +++ b/packages/expo/src/specs/NativeClerkUserProfileView.ts @@ -8,7 +8,7 @@ import type { BubblingEventHandler } from 'react-native/Libraries/Types/CodegenT type ProfileEvent = Readonly<{ type: string }>; interface NativeProps extends ViewProps { - isDismissable?: boolean; + isDismissible?: boolean; onProfileEvent?: BubblingEventHandler; } From b9b0e3876f63122064311846fd017b3d5df07c07 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:26:57 -0400 Subject: [PATCH 14/14] Default prebuilt Expo views to dismissible --- packages/expo/ios/ClerkAuthNativeView.swift | 4 ++-- packages/expo/ios/ClerkUserProfileNativeView.swift | 4 ++-- packages/expo/src/native/AuthView.tsx | 2 +- packages/expo/src/native/AuthView.types.ts | 2 +- packages/expo/src/native/UserProfileView.tsx | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo/ios/ClerkAuthNativeView.swift index 18760106a0b..2dee6dfae7b 100644 --- a/packages/expo/ios/ClerkAuthNativeView.swift +++ b/packages/expo/ios/ClerkAuthNativeView.swift @@ -4,7 +4,7 @@ import UIKit public class ClerkAuthNativeView: UIView { private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) private var currentMode: String = "signInOrUp" - private var currentDismissible: Bool = false + private var currentDismissible: Bool = true private var hasInitialized: Bool = false private var didCompleteAuthentication: Bool = false private var dismissalEventSent: Bool = false @@ -22,7 +22,7 @@ public class ClerkAuthNativeView: UIView { @objc var isDismissible: NSNumber? { didSet { - let newDismissible = isDismissible?.boolValue ?? false + let newDismissible = isDismissible?.boolValue ?? true guard newDismissible != currentDismissible else { return } currentDismissible = newDismissible if hasInitialized { updateView() } diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo/ios/ClerkUserProfileNativeView.swift index 3af52ea0955..b2380ce89fc 100644 --- a/packages/expo/ios/ClerkUserProfileNativeView.swift +++ b/packages/expo/ios/ClerkUserProfileNativeView.swift @@ -3,7 +3,7 @@ import UIKit public class ClerkUserProfileNativeView: UIView { private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) - private var currentDismissible: Bool = false + private var currentDismissible: Bool = true private var hasInitialized: Bool = false private var didSignOut = false private var dismissalEventSent = false @@ -12,7 +12,7 @@ public class ClerkUserProfileNativeView: UIView { @objc var isDismissible: NSNumber? { didSet { - let newDismissible = isDismissible?.boolValue ?? false + let newDismissible = isDismissible?.boolValue ?? true guard newDismissible != currentDismissible else { return } currentDismissible = newDismissible if hasInitialized { updateView() } diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx index 14de4d0e534..7c10c17eae6 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo/src/native/AuthView.tsx @@ -34,7 +34,7 @@ import type { AuthViewProps } from './AuthView.types'; * * @see {@link https://clerk.com/docs/components/authentication/sign-in} Clerk Sign-In Documentation */ -export function AuthView({ mode = 'signInOrUp', isDismissible = false, onDismiss }: AuthViewProps) { +export function AuthView({ mode = 'signInOrUp', isDismissible = true, onDismiss }: AuthViewProps) { const handleAuthEvent = useCallback( (event: { nativeEvent: { type: string } }) => { if (event.nativeEvent.type === 'dismissed') { diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts index a4886855072..6723c6d201a 100644 --- a/packages/expo/src/native/AuthView.types.ts +++ b/packages/expo/src/native/AuthView.types.ts @@ -34,7 +34,7 @@ export interface AuthViewProps { * When `false`, the user must complete authentication to close the view. * Use this for flows where authentication is required to proceed. * - * @default false + * @default true */ isDismissible?: boolean; diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo/src/native/UserProfileView.tsx index 276ebf4a5c5..1263569d5c2 100644 --- a/packages/expo/src/native/UserProfileView.tsx +++ b/packages/expo/src/native/UserProfileView.tsx @@ -15,7 +15,7 @@ export interface UserProfileViewProps { * This controls the native view's built-in dismiss button. It does not present * a modal; render `UserProfileView` inside your own `Modal`, sheet, or route. * - * @default false + * @default true */ isDismissible?: boolean; @@ -60,7 +60,7 @@ export interface UserProfileViewProps { * * @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation */ -export function UserProfileView({ isDismissible = false, style, onDismiss }: UserProfileViewProps) { +export function UserProfileView({ isDismissible = true, style, onDismiss }: UserProfileViewProps) { const handleProfileEvent = useCallback( (event: { nativeEvent: { type: string } }) => { if (event.nativeEvent.type === 'dismissed') {