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. diff --git a/.changeset/remove-expo-present-auth.md b/.changeset/remove-expo-present-auth.md new file mode 100644 index 00000000000..46172be3bea --- /dev/null +++ b/.changeset/remove-expo-present-auth.md @@ -0,0 +1,7 @@ +--- +'@clerk/expo': major +--- + +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/AndroidManifest.xml b/packages/expo/android/src/main/AndroidManifest.xml index 4683222f409..e1131a6c37e 100644 --- a/packages/expo/android/src/main/AndroidManifest.xml +++ b/packages/expo/android/src/main/AndroidManifest.xml @@ -1,17 +1,4 @@ - - - - - diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt deleted file mode 100644 index 1c8049adba6..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt +++ /dev/null @@ -1,306 +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.compose.BackHandler -import androidx.activity.compose.setContent -import java.util.concurrent.atomic.AtomicBoolean -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -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.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.clerk.api.Clerk -import com.clerk.api.signin.SignIn -import com.clerk.api.signin.prepareSecondFactor -import com.clerk.api.signup.SignUp -import com.clerk.api.signup.prepareVerification -import com.clerk.api.network.serialization.onSuccess -import com.clerk.api.network.serialization.onFailure -import com.clerk.api.network.serialization.errorMessage -import com.clerk.ui.auth.AuthView -import kotlinx.coroutines.delay - -/** - * Activity that hosts Clerk's AuthView Compose component. - * - * This activity is launched from ClerkExpoModule to present a full-screen - * authentication modal (sign-in, sign-up, or combined flow). - * - * Intent extras: - * - "mode": String - "signIn", "signUp", or "signInOrUp" (default) - * - "dismissable": Boolean - whether back press dismisses (default: true) - * - * Result: - * - RESULT_OK: Auth completed successfully (session is available via Clerk.session) - * - RESULT_CANCELED: User dismissed the modal - */ -class ClerkAuthActivity : ComponentActivity() { - - companion object { - private const val TAG = "ClerkAuthActivity" - private const val CLIENT_SYNC_MAX_ATTEMPTS = 30 - private const val CLIENT_SYNC_INTERVAL_MS = 100L - private const val POLL_INTERVAL_MS = 500L - - private fun debugLog(tag: String, message: String) { - if (BuildConfig.DEBUG) { - Log.d(tag, message) - } - } - } - - private val authCompleteGuard = AtomicBoolean(false) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val mode = intent.getStringExtra(ClerkExpoModule.EXTRA_MODE) ?: "signInOrUp" - val dismissable = intent.getBooleanExtra(ClerkExpoModule.EXTRA_DISMISSABLE, true) - - // Track if we had a session when we started (to detect new sign-in) - val initialSession = Clerk.session - debugLog(TAG, "onCreate - hasInitialSession: ${initialSession != null}, mode: $mode") - - setContent { - // Observe initialization state - val isInitialized by Clerk.isInitialized.collectAsStateWithLifecycle() - - // Observe both session and user state for completion - val session by Clerk.sessionFlow.collectAsStateWithLifecycle() - val user by Clerk.userFlow.collectAsStateWithLifecycle() - - // Track if the client has been synced (environment is ready) - // We need to wait for the client to sync before showing AuthView - var isClientReady by remember { mutableStateOf(false) } - - // Track when auth is complete to hide AuthView before finishing - // This prevents the "NavDisplay backstack cannot be empty" crash - var isAuthComplete by remember { mutableStateOf(false) } - - // Wait for SDK to be fully initialized AND client to sync - // The client sync happens after isInitialized becomes true - LaunchedEffect(isInitialized) { - if (isInitialized) { - // Give the client a moment to sync after initialization - // The SDK needs time to fetch the environment configuration - var attempts = 0 - while (attempts < CLIENT_SYNC_MAX_ATTEMPTS) { - val client = Clerk.client - if (client != null) { - debugLog(TAG, "Client is ready") - isClientReady = true - break - } - delay(CLIENT_SYNC_INTERVAL_MS) - attempts++ - } - if (!isClientReady) { - Log.w(TAG, "Client did not become ready after 3 seconds, showing AuthView anyway") - isClientReady = true - } - } - } - - // Track last signUp ID to detect when a new signUp is created - var lastSignUpId by remember { mutableStateOf(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/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 7c822cfda40..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 @@ -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,12 +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.ActivityEventListener +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.ReadableMap 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,46 +32,56 @@ private fun debugLog(tag: String, message: String) { } class ClerkExpoModule(reactContext: ReactApplicationContext) : - NativeClerkModuleSpec(reactContext), - ActivityEventListener { + NativeClerkModuleSpec(reactContext) { - companion object { - const val CLERK_AUTH_REQUEST_CODE = 9001 - const val CLERK_PROFILE_REQUEST_CODE = 9002 + private val coroutineScope = CoroutineScope(Dispatchers.Main) - // Intent extras - const val EXTRA_DISMISSABLE = "dismissable" - const val EXTRA_PUBLISHABLE_KEY = "publishableKey" - const val EXTRA_MODE = "mode" + companion object { + private var sharedReactContext: ReactApplicationContext? = null + private var listenerCount = 0 - // Result extras - const val RESULT_SESSION_ID = "sessionId" - const val RESULT_CANCELLED = "cancelled" + fun emitAuthStateChange(type: String, sessionId: String?) { + if (listenerCount <= 0) { + return + } - // Pending promises for activity results - private var pendingAuthPromise: Promise? = null - private var pendingProfilePromise: Promise? = null + val event = Arguments.createMap().apply { + putString("type", type) + if (sessionId == null) { + putNull("sessionId") + } else { + putString("sessionId", sessionId) + } + } - // Store publishable key for passing to activities - private var publishableKey: String? = null + sharedReactContext + ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + ?.emit("onAuthStateChange", event) + } } - private val coroutineScope = CoroutineScope(Dispatchers.Main) - init { - reactContext.addActivityEventListener(this) + 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 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. @@ -95,10 +103,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() @@ -161,67 +165,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 - 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 @@ -306,95 +249,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } } - // MARK: - Activity Result Handling - - 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) - } - } - - override fun onNewIntent(intent: Intent) { - // 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 - - // 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/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/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/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/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 e77ad21ddf0..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt +++ /dev/null @@ -1,102 +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 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) - 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 7b82bd1ec20..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt +++ /dev/null @@ -1,52 +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 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 - */ - 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/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.m b/packages/expo/ios/ClerkExpoModule.m index febfe003c61..3e6bad35035 100644 --- a/packages/expo/ios/ClerkExpoModule.m +++ b/packages/expo/ios/ClerkExpoModule.m @@ -8,14 +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) - RCT_EXTERN_METHOD(getSession:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index efd1e142445..4c9114df97e 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,13 +11,10 @@ public var clerkViewFactory: ClerkViewFactoryProtocol? // Protocol that the app target implements to provide Clerk views public protocol ClerkViewFactoryProtocol { - // Modal presentation (existing) - 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 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 @@ -56,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: [ @@ -66,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, @@ -102,73 +83,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, - 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, @@ -216,15 +130,95 @@ 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 = 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 +227,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 +252,76 @@ 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") } } - override public func removeFromSuperview() { - isInvalidated = true - dismissAuthModal() - super.removeFromSuperview() + private func sendAuthEvent(type: String) { + onAuthEvent?(["type": type]) } - // 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 + 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 } } @@ -379,14 +329,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() } } } @@ -404,45 +356,56 @@ public class ClerkUserProfileNativeView: UIView { 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() { - // 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]) - - // Also emit module-level event for sign-out detection 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(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/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 7e1925f41be..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? { - 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 - ) -> UIViewController? { - let wrapper = ClerkProfileWrapperViewController( - dismissable: dismissable, - lightTheme: lightTheme, - darkTheme: darkTheme, - completion: completion - ) - return wrapper - } - // MARK: - Inline View Creation public func createAuthView( @@ -209,6 +182,18 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { ) } + public func createUserButton( + onEvent: @escaping (String, [String: Any]) -> 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 { @@ -420,167 +405,38 @@ 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 +// MARK: - Inline User Button Wrapper (for embedded rendering) - 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 +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 = AuthView(mode: mode, isDismissable: dismissable) + let view = UserButton() .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 { - 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])) + let themedView = Group { + if let theme { + view.environment(\.clerkTheme, theme) + } else { + view + } } - } - - 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 + 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 + } } } - } - } -} - -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 - } } } 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/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/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/AuthView.tsx b/packages/expo/src/native/AuthView.tsx index a101a7cd0ad..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. * @@ -63,7 +13,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,49 +34,14 @@ 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) { - 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); - } - } - }, []); - +export function AuthView({ mode = 'signInOrUp', isDismissable = false, onDismiss }: AuthViewProps) { 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 === '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); - } + (event: { nativeEvent: { type: string } }) => { + if (event.nativeEvent.type === 'dismissed') { + onDismiss?.(); } }, - [syncSession], + [onDismiss], ); 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; } 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', - }, -}); diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx index 4e0795970ff..96bf4e37da6 100644 --- a/packages/expo/src/native/UserButton.tsx +++ b/packages/expo/src/native/UserButton.tsx @@ -1,257 +1,38 @@ -import { useClerk, useUser } from '@clerk/react'; -import { useEffect, useRef, useState } from 'react'; -import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { StyleSheet } 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) -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. - */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface UserButtonProps {} +import NativeClerkUserButtonView from '../specs/NativeClerkUserButtonView'; +import { isNativeSupported } from '../utils/native-module'; /** - * 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. + * `UserButton` renders the platform-native Clerk user button. Tapping it opens + * the native user profile surface, matching Clerk's iOS and Android SDKs. * - * 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. - * - * @example Basic usage in a header - * ```tsx - * import { UserButton } from '@clerk/expo/native'; - * - * export default function Header() { - * return ( - * - * My App - * - * - * ); - * } - * ``` - * - * @example Reacting to sign-out + * @example * ```tsx * import { UserButton } from '@clerk/expo/native'; - * import { useAuth } from '@clerk/expo'; - * - * export default function Header() { - * const { isSignedIn } = useAuth(); * - * useEffect(() => { - * if (!isSignedIn) router.replace('/sign-in'); - * }, [isSignedIn]); - * - * return ; + * export default function Home() { + * return ; * } * ``` * - * @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) { - 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 - 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); - - 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 ( - - ? - - ); +export function UserButton() { + if (!isNativeSupported || !NativeClerkUserButtonView) { + return null; } - return ( - void handlePress()} - style={styles.button} - > - {user?.imageUrl ? ( - - ) : ( - - {getInitials(user)} - - )} - - ); + return ; } const styles = StyleSheet.create({ - button: { - width: '100%', - height: '100%', - 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 f102cdee2b7..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. @@ -13,8 +12,8 @@ export interface UserProfileViewProps { /** * Whether the inline profile view shows a dismiss button. * - * This controls the native view's built-in dismiss button — it does not - * present a modal. To present a native modal, use the `useUserProfileModal()` hook. + * 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 */ @@ -24,6 +23,11 @@ export interface UserProfileViewProps { * Style applied to the container view. */ style?: StyleProp; + + /** + * Called when the user dismisses the native profile view. + */ + onDismiss?: () => void; } /** @@ -33,7 +37,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,37 +60,14 @@ export interface UserProfileViewProps { * * @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation */ -export function UserProfileView({ isDismissable = false, style }: UserProfileViewProps) { - const clerk = useClerk(); - const signOutTriggered = useRef(false); - +export function UserProfileView({ isDismissable = false, style, onDismiss }: UserProfileViewProps) { const handleProfileEvent = useCallback( - async (event: { nativeEvent: { type: string; data: string } }) => { - const { type } = event.nativeEvent; - - 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); - } - } - } + (event: { nativeEvent: { type: string } }) => { + if (event.nativeEvent.type === 'dismissed') { + onDismiss?.(); } }, - [clerk], + [onDismiss], ); if (!isNativeSupported || !NativeClerkUserProfileView) { diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts index 8ccd60b6f2c..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 that opens native profile modal + * - {@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 1c38d2c1f92..46c06ae6dac 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -3,11 +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; - presentAuth(options: UnsafeObject): Promise; - presentUserProfile(options: UnsafeObject): 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;