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;