diff --git a/.changeset/remove-expo-present-auth.md b/.changeset/remove-expo-present-auth.md
new file mode 100644
index 00000000000..4773e129856
--- /dev/null
+++ b/.changeset/remove-expo-present-auth.md
@@ -0,0 +1,9 @@
+---
+'@clerk/expo': minor
+---
+
+Update Expo's beta native prebuilt components to more closely match the behavior of Clerk's native iOS and Android SDKs.
+
+Previously, native auth and profile views relied on Expo-specific presentation behavior. `AuthView` and `UserProfileView` are now app-presented components, with dismissal handled through `onDismiss`. This also improves session synchronization between Clerk's JavaScript and native layers.
+
+**Note:** This includes native changes, so rebuild your native app after upgrading (`expo prebuild --clean` or a new EAS build).
diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle
index 4bc154232d7..b97e3005363 100644
--- a/packages/expo/android/build.gradle
+++ b/packages/expo/android/build.gradle
@@ -4,10 +4,9 @@ plugins {
id 'org.jetbrains.kotlin.plugin.compose' version '2.1.20'
}
-// Required for React Native codegen to generate Fabric component descriptors
-if (project.hasProperty("newArchEnabled") && project.newArchEnabled == "true") {
- apply plugin: "com.facebook.react"
-}
+def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+apply from: expoModulesCorePlugin
+applyKotlinExpoModulesCorePlugin()
group = 'com.clerk.expo'
version = '1.0.0'
@@ -72,7 +71,7 @@ android {
sourceSets {
main {
- java.srcDirs = ['src/main/java', "${project.buildDir}/generated/source/codegen/java"]
+ java.srcDirs = ['src/main/java']
}
}
}
@@ -96,8 +95,7 @@ try {
}
dependencies {
- // React Native
- implementation 'com.facebook.react:react-native:+'
+ implementation project(':expo-modules-core')
// Credential Manager for Google Sign-In with nonce support
implementation "androidx.credentials:credentials:$credentialsVersion"
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
deleted file mode 100644
index ce948f7a8a4..00000000000
--- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt
+++ /dev/null
@@ -1,185 +0,0 @@
-package expo.modules.clerk
-
-import android.content.Context
-import android.content.ContextWrapper
-import android.util.Log
-import android.widget.FrameLayout
-import androidx.activity.ComponentActivity
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.Recomposer
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.AndroidUiDispatcher
-import androidx.compose.ui.platform.ComposeView
-import androidx.lifecycle.ViewModelStore
-import androidx.lifecycle.ViewModelStoreOwner
-import androidx.lifecycle.compose.LocalLifecycleOwner
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.lifecycle.setViewTreeLifecycleOwner
-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.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
-
-private const val TAG = "ClerkAuthExpoView"
-
-private fun debugLog(tag: String, message: String) {
- if (BuildConfig.DEBUG) {
- Log.d(tag, message)
- }
-}
-
-class ClerkAuthNativeView(context: Context) : FrameLayout(context) {
- var mode: String = "signInOrUp"
- var isDismissable: Boolean = true
-
- private val activity: ComponentActivity? = findActivity(context).also {
- // At cold start, ClerkExpoModule.configure() may run before React's
- // host-resume sync — meaning getCurrentActivity() returns null there.
- // This view's construction is a reliable second hook: we know the Activity
- // is available (we just walked the context to find it) and we're about to
- // render Google sign-in / passkey buttons that need it.
- if (it != null) Clerk.attachActivity(it)
- }
-
- // Per-view ViewModelStoreOwner so the AuthView's ViewModels (including its
- // navigation state) are scoped to THIS view instance, not the activity.
- // Without this, the AuthView's navigation persists across mount/unmount
- // cycles within the same activity, leaving the user stuck on whatever screen
- // (e.g. "Get help") was last navigated to before sign-out.
- private val viewModelStoreOwner = object : ViewModelStoreOwner {
- private val store = ViewModelStore()
- override val viewModelStore: ViewModelStore = store
- }
-
- private var recomposer: Recomposer? = null
- private var recomposerJob: kotlinx.coroutines.Job? = null
-
- private val composeView = ComposeView(context).also { view ->
- activity?.let { act ->
- view.setViewTreeLifecycleOwner(act)
- view.setViewTreeViewModelStoreOwner(act)
- view.setViewTreeSavedStateRegistryOwner(act)
-
- // Create an explicit Recomposer to bypass windowRecomposer resolution.
- // In Compose 1.7+, windowRecomposer looks at rootView which may not have
- // lifecycle owners in React Native Fabric's detached view trees.
- 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 onDetachedFromWindow() {
- recomposer?.cancel()
- recomposerJob?.cancel()
- // Clear our per-view ViewModelStore so any AuthView ViewModels are GC'd.
- viewModelStoreOwner.viewModelStore.clear()
- super.onDetachedFromWindow()
- }
-
- // Track the initial session to detect new sign-ins. Captured at construction
- // time, but may capture a stale session if the view is mounted before signOut
- // has finished clearing local state — so the LaunchedEffect below uses
- // session id inequality (not null-to-value) to detect new sign-ins.
- private var initialSessionId: String? = Clerk.session?.id
- private var authCompletedSent: Boolean = false
-
- fun setupView() {
- debugLog(TAG, "setupView - mode: $mode, isDismissable: $isDismissable, activity: $activity")
-
- composeView.setContent {
- val session by Clerk.sessionFlow.collectAsStateWithLifecycle()
-
- // Detect auth completion: any session that's different from the one we
- // started with (captures fresh sign-ins, sign-in-after-sign-out, etc.)
- LaunchedEffect(session) {
- val currentSession = session
- val currentId = currentSession?.id
- if (currentSession != null && currentId != initialSessionId && !authCompletedSent) {
- debugLog(TAG, "Auth completed - new session: $currentId (initial: $initialSessionId)")
- authCompletedSent = true
- sendEvent("signInCompleted", mapOf(
- "sessionId" to currentSession.id,
- "type" to "signIn"
- ))
- }
- }
-
- // Provide the Activity as ViewModelStoreOwner so Clerk's viewModel() calls work
- val content = @androidx.compose.runtime.Composable {
- MaterialTheme {
- Surface(
- modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
- ) {
- AuthView(
- modifier = Modifier.fillMaxSize(),
- clerkTheme = Clerk.customTheme
- )
- }
- }
- }
-
- if (activity != null) {
- CompositionLocalProvider(
- // Per-view ViewModelStore so AuthView's navigation state doesn't
- // leak between mounts within the same MainActivity lifetime.
- LocalViewModelStoreOwner provides viewModelStoreOwner,
- LocalLifecycleOwner provides activity,
- LocalSavedStateRegistryOwner provides activity,
- ) {
- content()
- }
- } else {
- Log.e(TAG, "No ComponentActivity found!")
- content()
- }
- }
- }
-
- 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
- while (ctx != null) {
- if (ctx is ComponentActivity) return ctx
- ctx = (ctx as? ContextWrapper)?.baseContext
- }
- return null
- }
- }
-}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt
deleted file mode 100644
index 9ff989d9ea8..00000000000
--- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package expo.modules.clerk
-
-import com.facebook.react.common.MapBuilder
-import com.facebook.react.uimanager.SimpleViewManager
-import com.facebook.react.uimanager.ThemedReactContext
-import com.facebook.react.uimanager.annotations.ReactProp
-import com.facebook.react.viewmanagers.ClerkAuthViewManagerInterface
-
-class ClerkAuthViewManager : SimpleViewManager(),
- ClerkAuthViewManagerInterface {
-
- override fun getName(): String = "ClerkAuthView"
-
- override fun createViewInstance(reactContext: ThemedReactContext): ClerkAuthNativeView {
- return ClerkAuthNativeView(reactContext)
- }
-
- @ReactProp(name = "mode")
- override fun setMode(view: ClerkAuthNativeView, mode: String?) {
- view.mode = mode ?: "signInOrUp"
- view.setupView()
- }
-
- @ReactProp(name = "isDismissable")
- override fun setIsDismissable(view: ClerkAuthNativeView, isDismissable: Boolean) {
- view.isDismissable = isDismissable
- view.setupView()
- }
-
- override fun getExportedCustomBubblingEventTypeConstants(): MutableMap? {
- return MapBuilder.builder()
- .put("onAuthEvent", MapBuilder.of(
- "phasedRegistrationNames",
- MapBuilder.of("bubbled", "onAuthEvent")
- ))
- .build() as MutableMap
- }
-}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
new file mode 100644
index 00000000000..97cb37699d5
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
@@ -0,0 +1,105 @@
+package expo.modules.clerk
+
+import android.content.Context
+import android.util.Log
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import com.clerk.api.Clerk
+import com.clerk.ui.auth.AuthView
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import expo.modules.kotlin.viewevent.EventDispatcher
+
+private const val TAG = "ClerkAuthViewModule"
+
+private fun debugLog(tag: String, message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d(tag, message)
+ }
+}
+
+class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
+ var isDismissible: Boolean = true
+ var mode: String? = null
+
+ private val onAuthEvent by EventDispatcher()
+
+ init {
+ // At cold start, ClerkExpoModule.configure() may run before React's
+ // host-resume sync, so this view's construction is a reliable second hook.
+ activity?.let { Clerk.attachActivity(it) }
+ }
+
+ // Per-view ViewModelStoreOwner so the AuthView's ViewModels (including its
+ // navigation state) are scoped to THIS view instance, not the activity.
+ // Without this, the AuthView's navigation persists across mount/unmount
+ // cycles within the same activity, leaving the user stuck on whatever screen
+ // (e.g. "Get help") was last navigated to before sign-out.
+ private val viewModelStoreOwner = object : ViewModelStoreOwner {
+ private val store = ViewModelStore()
+ override val viewModelStore: ViewModelStore = store
+ }
+
+ private var dismissalEventSent: Boolean = false
+
+ override fun localViewModelStoreOwner(): ViewModelStoreOwner = viewModelStoreOwner
+
+ override fun onHostDetachedFromWindow() {
+ // Clear our per-view ViewModelStore so any AuthView ViewModels are GC'd.
+ viewModelStoreOwner.viewModelStore.clear()
+ }
+
+ @Composable
+ override fun Content() {
+ debugLog(TAG, "setupView - isDismissible: $isDismissible, activity: $activity")
+
+ AuthView(
+ modifier = Modifier.fillMaxSize(),
+ clerkTheme = Clerk.customTheme,
+ isDismissable = isDismissible,
+ onDismiss = ::sendDismissEvent,
+ onAuthComplete = {
+ sendDismissEvent()
+ },
+ )
+ }
+
+ private fun sendEvent(type: String) {
+ onAuthEvent(mapOf("type" to type))
+ }
+
+ private fun sendDismissEvent() {
+ if (dismissalEventSent) return
+ dismissalEventSent = true
+ sendEvent("dismissed")
+ }
+}
+
+class ClerkAuthViewModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("ClerkAuthView")
+
+ View(ClerkAuthNativeView::class) {
+ Events("onAuthEvent")
+
+ Prop("mode") { view: ClerkAuthNativeView, mode: String? ->
+ // clerk-android AuthView does not currently expose a public mode parameter.
+ // Keep this prop as an intentional no-op for cross-platform API parity.
+ view.mode = mode
+ }
+
+ Prop("isDismissible") { view: ClerkAuthNativeView, isDismissible: Boolean ->
+ view.isDismissible = isDismissible
+ }
+
+ OnViewDidUpdateProps { view: ClerkAuthNativeView ->
+ view.setupView()
+ }
+
+ }
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
new file mode 100644
index 00000000000..67d5f6d681c
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
@@ -0,0 +1,90 @@
+package expo.modules.clerk
+
+import android.content.Context
+import android.content.ContextWrapper
+import androidx.activity.ComponentActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.Recomposer
+import androidx.compose.ui.platform.AndroidUiDispatcher
+import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.savedstate.compose.LocalSavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.views.ExpoView
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
+ protected val activity: ComponentActivity? = findActivity(context)
+
+ 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 onDetachedFromWindow() {
+ recomposer?.cancel()
+ recomposerJob?.cancel()
+ onHostDetachedFromWindow()
+ super.onDetachedFromWindow()
+ }
+
+ fun setupView() {
+ composeView.setContent {
+ val viewModelStoreOwner = localViewModelStoreOwner()
+
+ if (activity != null && viewModelStoreOwner != null) {
+ CompositionLocalProvider(
+ LocalViewModelStoreOwner provides viewModelStoreOwner,
+ LocalLifecycleOwner provides activity,
+ LocalSavedStateRegistryOwner provides activity,
+ ) {
+ Content()
+ }
+ } else {
+ Content()
+ }
+ }
+ }
+
+ protected open fun localViewModelStoreOwner(): ViewModelStoreOwner? = activity
+
+ protected open fun onHostDetachedFromWindow() {}
+
+ @Composable
+ protected abstract fun Content()
+
+ companion object {
+ fun findActivity(context: Context): ComponentActivity? {
+ var ctx: Context? = context
+ while (ctx != null) {
+ if (ctx is ComponentActivity) return ctx
+ ctx = (ctx as? ContextWrapper)?.baseContext
+ }
+ return null
+ }
+ }
+}
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..9ef266fb523 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,24 +1,22 @@
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
import com.clerk.api.Clerk
+import com.clerk.api.network.model.client.Client
+import com.clerk.api.network.model.error.firstMessage
import com.clerk.api.network.serialization.ClerkResult
import com.clerk.api.ui.ClerkColors
import com.clerk.api.ui.ClerkDesign
import com.clerk.api.ui.ClerkTheme
-import com.facebook.react.bridge.ActivityEventListener
-import com.facebook.react.bridge.Promise
-import com.facebook.react.bridge.ReactApplicationContext
-import com.facebook.react.bridge.ReactMethod
-import com.facebook.react.bridge.ReadableMap
-import com.facebook.react.bridge.WritableNativeMap
+import expo.modules.kotlin.Promise
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -33,75 +31,111 @@ private fun debugLog(tag: String, message: String) {
}
}
-class ClerkExpoModule(reactContext: ReactApplicationContext) :
- NativeClerkModuleSpec(reactContext),
- ActivityEventListener {
+class ClerkExpoModule : Module() {
+ private val coroutineScope = CoroutineScope(Dispatchers.Main)
+ private var clientStateObserverJob: Job? = null
+ private var lastObservedClient: Client? = null
+ private var configuredPublishableKey: String? = null
companion object {
- const val CLERK_AUTH_REQUEST_CODE = 9001
- const val CLERK_PROFILE_REQUEST_CODE = 9002
+ private var sharedInstance: ClerkExpoModule? = null
- // Intent extras
- const val EXTRA_DISMISSABLE = "dismissable"
- const val EXTRA_PUBLISHABLE_KEY = "publishableKey"
- const val EXTRA_MODE = "mode"
+ fun emitRefreshClient() {
+ sharedInstance?.sendEvent("refreshClient", emptyMap())
+ }
+ }
- // Result extras
- const val RESULT_SESSION_ID = "sessionId"
- const val RESULT_CANCELLED = "cancelled"
+ override fun definition() = ModuleDefinition {
+ Name("ClerkExpo")
- // Pending promises for activity results
- private var pendingAuthPromise: Promise? = null
- private var pendingProfilePromise: Promise? = null
+ Events("refreshClient")
- // Store publishable key for passing to activities
- private var publishableKey: String? = null
- }
+ OnCreate {
+ sharedInstance = this@ClerkExpoModule
+ }
- private val coroutineScope = CoroutineScope(Dispatchers.Main)
+ OnDestroy {
+ if (sharedInstance === this@ClerkExpoModule) {
+ sharedInstance = null
+ }
+ clientStateObserverJob?.cancel()
+ clientStateObserverJob = null
+ }
- init {
- reactContext.addActivityEventListener(this)
+ AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise ->
+ configure(pubKey, bearerToken, promise)
+ }
+
+ AsyncFunction("getSession") { promise: Promise ->
+ getSession(promise)
+ }
+
+ AsyncFunction("getClientToken") { promise: Promise ->
+ getClientToken(promise)
+ }
+
+ AsyncFunction("refreshClient") { promise: Promise ->
+ refreshClient(promise)
+ }
}
- override fun getName(): String = "ClerkExpo"
+ private val reactContext: Context?
+ get() = appContext.reactContext
+
+ private fun startClientStateObserver() {
+ if (clientStateObserverJob != null) {
+ return
+ }
+
+ lastObservedClient = Clerk.clientFlow.value
+
+ clientStateObserverJob = coroutineScope.launch {
+ Clerk.clientFlow.collect { client ->
+ if (client == lastObservedClient) {
+ return@collect
+ }
+
+ lastObservedClient = client
+ emitRefreshClient()
+ }
+ }
+ }
// MARK: - configure
- @ReactMethod
- override fun configure(pubKey: String, bearerToken: String?, promise: Promise) {
+ private fun configure(pubKey: String, bearerToken: String?, promise: Promise) {
+ val context = reactContext ?: run {
+ promise.reject("E_INIT_FAILED", "React context is not available", null)
+ return
+ }
+
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.
if (!bearerToken.isNullOrEmpty()) {
- reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
+ context.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
.edit()
.putString("DEVICE_TOKEN", bearerToken)
.apply()
}
- Clerk.initialize(reactApplicationContext, pubKey)
+ Clerk.initialize(context, pubKey)
+ startClientStateObserver()
// clerk-android registers ActivityLifecycleCallbacks during
// initialize(), but in React Native MainActivity has already passed
// onResume() by the time mounts and we reach this
// line, so the callbacks miss the initial activity. Without seeding,
// the first Credential Manager call (Google sign-in / passkeys)
// fails with MissingActivity until the user backgrounds and
- // foregrounds the app. getCurrentActivity() can be null here on
+ // foregrounds the app. currentActivity can be null here on
// 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.
+ appContext.currentActivity?.let { Clerk.attachActivity(it) }
// Must be set AFTER Clerk.initialize() because initialize()
// resets customTheme to its `theme` parameter (default null).
- loadThemeFromAssets()
+ loadThemeFromAssets(context)
// Wait for initialization to complete with timeout
try {
@@ -122,22 +156,72 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
} else {
"Clerk initialization timed out after 10 seconds"
}
- promise.reject("E_TIMEOUT", message)
+ promise.reject("E_TIMEOUT", message, null)
return@launch
}
// Check for initialization errors
val error = Clerk.initializationError.value
if (error != null) {
- promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}")
+ promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}", null)
} else {
+ configuredPublishableKey = pubKey
promise.resolve(null)
}
return@launch
}
+ val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey
+ if (activePublishableKey != null && activePublishableKey != pubKey) {
+ Clerk.switchConfiguration(context, pubKey)
+ startClientStateObserver()
+ appContext.currentActivity?.let { Clerk.attachActivity(it) }
+ loadThemeFromAssets(context)
+
+ try {
+ withTimeout(10_000L) {
+ Clerk.isInitialized.first { it }
+ }
+ } catch (e: TimeoutCancellationException) {
+ val initError = Clerk.initializationError.value
+ val message = if (initError != null) {
+ "Clerk reconfiguration timed out: ${initError.message}"
+ } else {
+ "Clerk reconfiguration timed out after 10 seconds"
+ }
+ promise.reject("E_TIMEOUT", message, null)
+ return@launch
+ }
+
+ val error = Clerk.initializationError.value
+ if (error != null) {
+ promise.reject("E_RECONFIGURE_FAILED", "Failed to reconfigure Clerk SDK: ${error.message}", null)
+ return@launch
+ }
+
+ if (!bearerToken.isNullOrEmpty()) {
+ val result = Clerk.updateDeviceToken(bearerToken)
+ if (result is ClerkResult.Failure) {
+ debugLog(TAG, "configure - updateDeviceToken after reconfigure failed: ${result.error}")
+ }
+
+ try {
+ withTimeout(5_000L) {
+ Clerk.sessionFlow.first { it != null }
+ }
+ } catch (_: TimeoutCancellationException) {
+ debugLog(TAG, "configure - session did not appear after reconfigure token update")
+ }
+ }
+
+ configuredPublishableKey = pubKey
+ promise.resolve(null)
+ return@launch
+ }
+
// Already initialized — use the public SDK API to update
// the device token and trigger a client/environment refresh.
+ startClientStateObserver()
if (!bearerToken.isNullOrEmpty()) {
val result = Clerk.updateDeviceToken(bearerToken)
if (result is ClerkResult.Failure) {
@@ -161,71 +245,9 @@ 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
- override fun getSession(promise: Promise) {
+ private fun getSession(promise: Promise) {
if (!Clerk.isInitialized.value) {
// Return null when not initialized (matches iOS behavior)
// so callers can proceed to call configure() with a bearer token.
@@ -236,28 +258,28 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
val session = Clerk.session
val user = Clerk.user
- val result = WritableNativeMap()
+ val result = mutableMapOf()
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)
+ result["session"] = mapOf(
+ "id" to it.id,
+ "status" to it.status.name,
+ "userId" to it.user?.id
+ )
}
user?.let {
val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId }
val primaryPhone = it.phoneNumbers.find { p -> p.id == it.primaryPhoneNumberId }
- 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)
- userMap.putString("primaryPhoneNumber", primaryPhone?.phoneNumber)
- result.putMap("user", userMap)
+ result["user"] = mapOf(
+ "id" to it.id,
+ "firstName" to it.firstName,
+ "lastName" to it.lastName,
+ "imageUrl" to it.imageUrl,
+ "primaryEmailAddress" to primaryEmail?.emailAddress,
+ "primaryPhoneNumber" to primaryPhone?.phoneNumber
+ )
}
promise.resolve(result)
@@ -265,8 +287,7 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
// MARK: - getClientToken
- @ReactMethod
- override fun getClientToken(promise: Promise) {
+ private fun getClientToken(promise: Promise) {
try {
// Use the SDK's public API which handles encrypted storage transparently.
// Direct SharedPreferences reads break on clerk-android >= 1.0.11 where
@@ -279,127 +300,35 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
}
}
- // MARK: - signOut
+ // MARK: - refreshClient
- @ReactMethod
- override fun signOut(promise: Promise) {
+ private fun refreshClient(promise: Promise) {
if (!Clerk.isInitialized.value) {
- // Clear DEVICE_TOKEN from SharedPreferences even when not initialized,
- // so the next Clerk.initialize() doesn't boot with a stale client token.
- reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
- .edit()
- .remove("DEVICE_TOKEN")
- .apply()
promise.resolve(null)
return
}
coroutineScope.launch {
try {
- Clerk.auth.signOut()
- // Client refresh after sign-out is handled by the clerk-android
- // SDK (SignOutService.signOut calls Client.getSkippingClientId).
- promise.resolve(null)
+ when (val result = Clerk.refreshClient()) {
+ is ClerkResult.Failure -> promise.reject(
+ "E_REFRESH_CLIENT_FAILED",
+ result.error?.firstMessage() ?: result.throwable?.message ?: "Client refresh failed",
+ null
+ )
+ is ClerkResult.Success -> promise.resolve(null)
+ }
} catch (e: Exception) {
- promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign out failed", e)
- }
- }
- }
-
- // 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.reject("E_REFRESH_CLIENT_FAILED", e.message ?: "Client refresh failed", e)
}
-
- 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() {
+ private fun loadThemeFromAssets(context: Context) {
try {
- val jsonString = reactApplicationContext.assets
+ val jsonString = context.assets
.open("clerk_theme.json")
.bufferedReader()
.use { it.readText() }
@@ -470,7 +399,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
}
private fun JSONObject.optStringColor(key: String): Color? {
- val value = optString(key, null) ?: return null
+ if (!has(key) || isNull(key)) return null
+ val value = optString(key)
return parseHexColor(value)
}
}
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
deleted file mode 100644
index 9a97309ac5e..00000000000
--- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package expo.modules.clerk
-
-import com.facebook.react.TurboReactPackage
-import com.facebook.react.bridge.NativeModule
-import com.facebook.react.bridge.ReactApplicationContext
-import com.facebook.react.module.model.ReactModuleInfo
-import com.facebook.react.module.model.ReactModuleInfoProvider
-import com.facebook.react.uimanager.ViewManager
-
-class ClerkPackage : TurboReactPackage() {
-
- override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
- return when (name) {
- NativeClerkModuleSpec.NAME -> ClerkExpoModule(reactContext)
- NativeClerkGoogleSignInSpec.NAME -> expo.modules.clerk.googlesignin.ClerkGoogleSignInModule(reactContext)
- else -> null
- }
- }
-
- override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
- return ReactModuleInfoProvider {
- mapOf(
- NativeClerkModuleSpec.NAME to ReactModuleInfo(
- NativeClerkModuleSpec.NAME,
- ClerkExpoModule::class.java.name,
- false, false, true, false, true
- ),
- NativeClerkGoogleSignInSpec.NAME to ReactModuleInfo(
- NativeClerkGoogleSignInSpec.NAME,
- expo.modules.clerk.googlesignin.ClerkGoogleSignInModule::class.java.name,
- false, false, true, false, true
- ),
- )
- }
- }
-
- override fun createViewManagers(reactContext: ReactApplicationContext): List> {
- return listOf(
- ClerkAuthViewManager(),
- ClerkUserProfileViewManager(),
- )
- }
-}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
new file mode 100644
index 00000000000..6ad219d65fc
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
@@ -0,0 +1,42 @@
+package expo.modules.clerk
+
+import android.content.Context
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.clerk.api.Clerk
+import com.clerk.ui.userbutton.UserButton
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
+ init {
+ activity?.let { Clerk.attachActivity(it) }
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ setupView()
+ }
+
+ @Composable
+ override fun Content() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ UserButton(clerkTheme = Clerk.customTheme)
+ }
+ }
+}
+
+class ClerkUserButtonViewModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("ClerkUserButtonView")
+
+ View(ClerkUserButtonNativeView::class) {}
+ }
+}
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
deleted file mode 100644
index 8d3762a3be6..00000000000
--- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt
+++ /dev/null
@@ -1,144 +0,0 @@
-package expo.modules.clerk
-
-import android.content.Context
-import android.util.Log
-import android.widget.FrameLayout
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-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.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.userprofile.UserProfileView
-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
-
-private const val TAG = "ClerkUserProfileExpoView"
-
-class ClerkUserProfileNativeView(context: Context) : FrameLayout(context) {
- var isDismissable: Boolean = true
-
- private val activity = ClerkAuthNativeView.findActivity(context)
-
- 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 onDetachedFromWindow() {
- recomposer?.cancel()
- recomposerJob?.cancel()
- super.onDetachedFromWindow()
- }
-
- fun setupView() {
- Log.d(TAG, "setupView - isDismissable: $isDismissable")
-
- composeView.setContent {
- val session by Clerk.sessionFlow.collectAsStateWithLifecycle()
-
- var hadSession by remember { mutableStateOf(Clerk.session != null) }
-
- LaunchedEffect(session) {
- if (hadSession && session == null) {
- Log.d(TAG, "Sign-out detected")
- // Refresh the client from the server to clear any stale in-progress
- // signIn/signUp state. Without this, when the AuthView re-mounts after
- // sign-out it routes to the "Get help" fallback because the previous
- // user's signIn is still in Clerk.client. Clerk.auth.signOut() (called
- // internally by UserProfileView) only clears session/user state, not
- // the in-progress signIn.
- try {
- Client.getSkippingClientId()
- } catch (e: Exception) {
- Log.w(TAG, "Client.getSkippingClientId() after UserProfile sign-out failed: ${e.message}")
- }
- sendEvent("signedOut", emptyMap())
- }
- if (session != null) {
- hadSession = true
- }
- }
-
- val content = @androidx.compose.runtime.Composable {
- MaterialTheme {
- Surface(
- modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
- ) {
- UserProfileView(
- clerkTheme = Clerk.customTheme,
- onDismiss = {
- Log.d(TAG, "Profile dismissed")
- sendEvent("dismissed", emptyMap())
- }
- )
- }
- }
- }
-
- if (activity != null) {
- CompositionLocalProvider(
- LocalViewModelStoreOwner provides activity,
- LocalLifecycleOwner provides activity,
- LocalSavedStateRegistryOwner provides activity,
- ) {
- content()
- }
- } else {
- content()
- }
- }
- }
-
- private fun sendEvent(type: String, data: Map) {
- 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/ClerkUserProfileViewManager.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt
deleted file mode 100644
index bc5a338271e..00000000000
--- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package expo.modules.clerk
-
-import com.facebook.react.common.MapBuilder
-import com.facebook.react.uimanager.SimpleViewManager
-import com.facebook.react.uimanager.ThemedReactContext
-import com.facebook.react.uimanager.annotations.ReactProp
-import com.facebook.react.viewmanagers.ClerkUserProfileViewManagerInterface
-
-class ClerkUserProfileViewManager : SimpleViewManager(),
- ClerkUserProfileViewManagerInterface {
-
- override fun getName(): String = "ClerkUserProfileView"
-
- override fun createViewInstance(reactContext: ThemedReactContext): ClerkUserProfileNativeView {
- return ClerkUserProfileNativeView(reactContext)
- }
-
- @ReactProp(name = "isDismissable")
- override fun setIsDismissable(view: ClerkUserProfileNativeView, isDismissable: Boolean) {
- view.isDismissable = isDismissable
- view.setupView()
- }
-
- override fun getExportedCustomBubblingEventTypeConstants(): MutableMap? {
- return MapBuilder.builder()
- .put("onProfileEvent", MapBuilder.of(
- "phasedRegistrationNames",
- MapBuilder.of("bubbled", "onProfileEvent")
- ))
- .build() as MutableMap
- }
-}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
new file mode 100644
index 00000000000..aa87e40853b
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
@@ -0,0 +1,78 @@
+package expo.modules.clerk
+
+import android.content.Context
+import android.util.Log
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import com.clerk.api.Clerk
+import com.clerk.ui.userprofile.UserProfileView
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import expo.modules.kotlin.viewevent.EventDispatcher
+
+private const val TAG = "ClerkUserProfileViewModule"
+
+private fun debugLog(tag: String, message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d(tag, message)
+ }
+}
+
+class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
+ // clerk-android UserProfileView dismissibility is controlled by its onDismiss callback.
+ var isDismissible: Boolean = true
+ private val onProfileEvent by EventDispatcher()
+
+ private val viewModelStoreOwner = object : ViewModelStoreOwner {
+ private val store = ViewModelStore()
+ override val viewModelStore: ViewModelStore = store
+ }
+
+ override fun localViewModelStoreOwner(): ViewModelStoreOwner = viewModelStoreOwner
+
+ override fun onHostDetachedFromWindow() {
+ viewModelStoreOwner.viewModelStore.clear()
+ }
+
+ @Composable
+ override fun Content() {
+ debugLog(TAG, "setupView - isDismissible: $isDismissible")
+
+ UserProfileView(
+ clerkTheme = Clerk.customTheme,
+ onDismiss = {
+ debugLog(TAG, "Profile dismissed")
+ sendEvent("dismissed")
+ }
+ )
+ }
+
+ private fun sendEvent(type: String) {
+ onProfileEvent(mapOf("type" to type))
+ }
+}
+
+class ClerkUserProfileViewModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("ClerkUserProfileView")
+
+ View(ClerkUserProfileNativeView::class) {
+ Events("onProfileEvent")
+
+ Prop("isDismissible") { view: ClerkUserProfileNativeView, isDismissible: Boolean ->
+ // clerk-android does not expose the iOS-parity isDismissible API yet.
+ // Accept the prop for cross-platform RN API shape, and pass it through
+ // once the native SDK owns this behavior.
+ view.isDismissible = isDismissible
+ }
+
+ OnViewDidUpdateProps { view: ClerkUserProfileNativeView ->
+ view.setupView()
+ }
+ }
+ }
+}
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/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt
index 54183ce5552..68be6942cf4 100644
--- a/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt
@@ -1,6 +1,5 @@
package expo.modules.clerk.googlesignin
-import android.content.Context
import androidx.credentials.ClearCredentialStateRequest
import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
@@ -9,64 +8,75 @@ import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.NoCredentialException
-import com.facebook.react.bridge.Promise
-import com.facebook.react.bridge.ReactApplicationContext
-import com.facebook.react.bridge.ReactMethod
-import expo.modules.clerk.NativeClerkGoogleSignInSpec
-import com.facebook.react.bridge.ReadableMap
-import com.facebook.react.bridge.WritableNativeMap
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
+import expo.modules.kotlin.Promise
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
-class ClerkGoogleSignInModule(reactContext: ReactApplicationContext) :
- NativeClerkGoogleSignInSpec(reactContext) {
-
+class ClerkGoogleSignInModule : Module() {
private var webClientId: String? = null
private var hostedDomain: String? = null
private var autoSelectEnabled: Boolean = false
private val mainScope = CoroutineScope(Dispatchers.Main)
private val credentialManager: CredentialManager
- get() = CredentialManager.create(reactApplicationContext)
+ get() = CredentialManager.create(requireNotNull(appContext.reactContext))
+
+ override fun definition() = ModuleDefinition {
+ Name("ClerkGoogleSignIn")
+
+ Function("configure") { params: Map ->
+ configure(params)
+ }
- override fun getName(): String = "ClerkGoogleSignIn"
+ AsyncFunction("signIn") { params: Map?, promise: Promise ->
+ signIn(params, promise)
+ }
+
+ AsyncFunction("createAccount") { params: Map?, promise: Promise ->
+ createAccount(params, promise)
+ }
+
+ AsyncFunction("presentExplicitSignIn") { params: Map?, promise: Promise ->
+ presentExplicitSignIn(params, promise)
+ }
+
+ AsyncFunction("signOut") { promise: Promise ->
+ signOut(promise)
+ }
+ }
// MARK: - configure
- @ReactMethod
- override fun configure(params: ReadableMap) {
- webClientId = if (params.hasKey("webClientId")) params.getString("webClientId") else null
- hostedDomain = if (params.hasKey("hostedDomain")) params.getString("hostedDomain") else null
- autoSelectEnabled = if (params.hasKey("autoSelectEnabled")) params.getBoolean("autoSelectEnabled") else false
+ private fun configure(params: Map) {
+ webClientId = params["webClientId"] as? String
+ hostedDomain = params["hostedDomain"] as? String
+ autoSelectEnabled = params["autoSelectEnabled"] as? Boolean ?: false
}
// MARK: - signIn
- @ReactMethod
- override fun signIn(params: ReadableMap?, promise: Promise) {
+ private fun signIn(params: Map?, promise: Promise) {
val clientId = webClientId ?: run {
- promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.")
+ promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", null)
return
}
- val activity = getCurrentActivity() ?: run {
- promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available")
+ val activity = appContext.currentActivity ?: run {
+ promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available", null)
return
}
mainScope.launch {
try {
- val filterByAuthorized = params?.let {
- if (it.hasKey("filterByAuthorizedAccounts")) it.getBoolean("filterByAuthorizedAccounts") else true
- } ?: true
- val nonce = params?.let {
- if (it.hasKey("nonce")) it.getString("nonce") else null
- }
+ val filterByAuthorized = params?.get("filterByAuthorizedAccounts") as? Boolean ?: true
+ val nonce = params?.get("nonce") as? String
val googleIdOption = GetGoogleIdOption.Builder()
.setFilterByAuthorizedAccounts(filterByAuthorized)
@@ -101,23 +111,20 @@ class ClerkGoogleSignInModule(reactContext: ReactApplicationContext) :
// MARK: - createAccount
- @ReactMethod
- override fun createAccount(params: ReadableMap?, promise: Promise) {
+ private fun createAccount(params: Map?, promise: Promise) {
val clientId = webClientId ?: run {
- promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.")
+ promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", null)
return
}
- val activity = getCurrentActivity() ?: run {
- promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available")
+ val activity = appContext.currentActivity ?: run {
+ promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available", null)
return
}
mainScope.launch {
try {
- val nonce = params?.let {
- if (it.hasKey("nonce")) it.getString("nonce") else null
- }
+ val nonce = params?.get("nonce") as? String
val googleIdOption = GetGoogleIdOption.Builder()
.setFilterByAuthorizedAccounts(false) // Show all accounts for creation
@@ -151,23 +158,20 @@ class ClerkGoogleSignInModule(reactContext: ReactApplicationContext) :
// MARK: - presentExplicitSignIn
- @ReactMethod
- override fun presentExplicitSignIn(params: ReadableMap?, promise: Promise) {
+ private fun presentExplicitSignIn(params: Map?, promise: Promise) {
val clientId = webClientId ?: run {
- promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.")
+ promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", null)
return
}
- val activity = getCurrentActivity() ?: run {
- promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available")
+ val activity = appContext.currentActivity ?: run {
+ promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available", null)
return
}
mainScope.launch {
try {
- val nonce = params?.let {
- if (it.hasKey("nonce")) it.getString("nonce") else null
- }
+ val nonce = params?.get("nonce") as? String
val signInWithGoogleOption = GetSignInWithGoogleOption.Builder(clientId)
.apply {
@@ -198,8 +202,7 @@ class ClerkGoogleSignInModule(reactContext: ReactApplicationContext) :
// MARK: - signOut
- @ReactMethod
- override fun signOut(promise: Promise) {
+ private fun signOut(promise: Promise) {
mainScope.launch {
try {
credentialManager.clearCredentialState(ClearCredentialStateRequest())
@@ -219,35 +222,35 @@ class ClerkGoogleSignInModule(reactContext: ReactApplicationContext) :
try {
val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data)
- val userMap = WritableNativeMap().apply {
- putString("id", googleIdTokenCredential.id)
- putString("email", googleIdTokenCredential.id)
- putString("name", googleIdTokenCredential.displayName)
- putString("givenName", googleIdTokenCredential.givenName)
- putString("familyName", googleIdTokenCredential.familyName)
- putString("photo", googleIdTokenCredential.profilePictureUri?.toString())
- }
-
- val dataMap = WritableNativeMap().apply {
- putString("idToken", googleIdTokenCredential.idToken)
- putMap("user", userMap)
- }
-
- val responseMap = WritableNativeMap().apply {
- putString("type", "success")
- putMap("data", dataMap)
- }
-
- promise.resolve(responseMap)
+ val user = mapOf(
+ "id" to googleIdTokenCredential.id,
+ "email" to googleIdTokenCredential.id,
+ "name" to googleIdTokenCredential.displayName,
+ "givenName" to googleIdTokenCredential.givenName,
+ "familyName" to googleIdTokenCredential.familyName,
+ "photo" to googleIdTokenCredential.profilePictureUri?.toString()
+ )
+
+ val data = mapOf(
+ "idToken" to googleIdTokenCredential.idToken,
+ "user" to user
+ )
+
+ promise.resolve(
+ mapOf(
+ "type" to "success",
+ "data" to data
+ )
+ )
} catch (e: GoogleIdTokenParsingException) {
promise.reject("GOOGLE_SIGN_IN_ERROR", "Failed to parse Google ID token: ${e.message}", e)
}
} else {
- promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type: ${credential.type}")
+ promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type: ${credential.type}", null)
}
}
else -> {
- promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type")
+ promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type", null)
}
}
}
diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js
index e2bf4d163e9..26e0033b104 100644
--- a/packages/expo/app.plugin.js
+++ b/packages/expo/app.plugin.js
@@ -6,8 +6,8 @@
* 1. iOS is configured with Swift Package Manager dependency for clerk-ios
* 2. Android is configured with packaging exclusions for dependencies
*
- * Native modules are registered via react-native.config.js and standard
- * React Native autolinking (RCTViewManager / ReactPackage).
+ * Native modules are registered via Expo Modules autolinking on Android and
+ * React Native autolinking on iOS (RCTViewManager).
*/
const {
withXcodeProject,
@@ -214,7 +214,7 @@ const withClerkIOS = config => {
return config;
});
- // Inject ClerkViewFactory.register() call into AppDelegate.swift
+ // Inject ClerkNativeBridge.register() call into AppDelegate.swift
config = withDangerousMod(config, [
'ios',
async config => {
@@ -226,7 +226,7 @@ const withClerkIOS = config => {
let contents = fs.readFileSync(appDelegatePath, 'utf8');
// Check if already added
- if (!contents.includes('ClerkViewFactory.register()')) {
+ if (!contents.includes('ClerkNativeBridge.register()')) {
// Find the didFinishLaunchingWithOptions method and add the registration call
// Look for the return statement in didFinishLaunching
const pattern = /(func application\s*\([^)]*didFinishLaunchingWithOptions[^)]*\)[^{]*\{)/;
@@ -235,10 +235,10 @@ const withClerkIOS = config => {
if (match) {
// Insert after the opening brace of didFinishLaunching
const insertPoint = match.index + match[0].length;
- const registrationCode = '\n // Register Clerk native views\n ClerkViewFactory.register()\n';
+ const registrationCode = '\n // Register Clerk native bridge\n ClerkNativeBridge.register()\n';
contents = contents.slice(0, insertPoint) + registrationCode + contents.slice(insertPoint);
fs.writeFileSync(appDelegatePath, contents);
- console.log('✅ Added ClerkViewFactory.register() to AppDelegate.swift');
+ console.log('✅ Added ClerkNativeBridge.register() to AppDelegate.swift');
} else {
console.warn('⚠️ Could not find didFinishLaunchingWithOptions in AppDelegate.swift');
}
@@ -249,7 +249,7 @@ const withClerkIOS = config => {
},
]);
- // Then inject ClerkViewFactory.swift into the app target
+ // Then inject ClerkNativeBridge.swift into the app target
// This is required because the file uses `import ClerkKit` which is only available
// via SPM in the app target (CocoaPods targets can't see SPM packages)
config = withXcodeProject(config, config => {
@@ -258,26 +258,26 @@ const withClerkIOS = config => {
const projectName = config.modRequest.projectName;
const iosProjectPath = path.join(platformProjectRoot, projectName);
- // Find the ClerkViewFactory.swift source file using Node's module resolution,
+ // Find the ClerkNativeBridge.swift source file using Node's module resolution,
// which handles arbitrary nesting depths in pnpm/yarn/npm workspaces.
let sourceFile;
try {
const packageRoot = path.dirname(require.resolve('@clerk/expo/package.json'));
- sourceFile = path.join(packageRoot, 'ios', 'ClerkViewFactory.swift');
+ sourceFile = path.join(packageRoot, 'ios', 'ClerkNativeBridge.swift');
} catch {
sourceFile = null;
}
if (sourceFile && fs.existsSync(sourceFile)) {
// ALWAYS copy the file to ensure we have the latest version
- const targetFile = path.join(iosProjectPath, 'ClerkViewFactory.swift');
+ const targetFile = path.join(iosProjectPath, 'ClerkNativeBridge.swift');
fs.copyFileSync(sourceFile, targetFile);
- console.log('✅ Copied ClerkViewFactory.swift to app target');
+ console.log('✅ Copied ClerkNativeBridge.swift to app target');
// Add the file to the Xcode project manually
const xcodeProject = config.modResults;
- const relativePath = `${projectName}/ClerkViewFactory.swift`;
- const fileName = 'ClerkViewFactory.swift';
+ const relativePath = `${projectName}/ClerkNativeBridge.swift`;
+ const fileName = 'ClerkNativeBridge.swift';
try {
// Get the main target
@@ -295,7 +295,7 @@ const withClerkIOS = config => {
if (alreadyExists) {
// File is already in project, but we still copied the latest version
- console.log('✅ ClerkViewFactory.swift updated in app target');
+ console.log('✅ ClerkNativeBridge.swift updated in app target');
return config;
}
@@ -309,7 +309,7 @@ const withClerkIOS = config => {
isa: 'PBXFileReference',
lastKnownFileType: 'sourcecode.swift',
name: fileName,
- path: relativePath, // Use full relative path (projectName/ClerkViewFactory.swift)
+ path: relativePath, // Use full relative path (projectName/ClerkNativeBridge.swift)
sourceTree: '""',
};
@@ -379,16 +379,16 @@ const withClerkIOS = config => {
console.warn('⚠️ Could not find main PBXGroup for project');
}
- console.log('✅ Added ClerkViewFactory.swift to Xcode project');
+ console.log('✅ Added ClerkNativeBridge.swift to Xcode project');
} catch (addError) {
console.error('❌ Error adding file to Xcode project:', addError.message);
console.error(addError.stack);
}
} else {
- console.warn('⚠️ ClerkViewFactory.swift not found, skipping injection');
+ console.warn('⚠️ ClerkNativeBridge.swift not found, skipping injection');
}
} catch (error) {
- console.error('❌ Error injecting ClerkViewFactory.swift:', error.message);
+ console.error('❌ Error injecting ClerkNativeBridge.swift:', error.message);
}
return config;
@@ -551,8 +551,8 @@ const withClerkGoogleSignIn = config => {
* 2. Android gets packaging exclusions for dependency conflicts
* 3. Google Sign-In URL scheme is configured (if env var is set)
*
- * Native modules are registered via react-native.config.js and standard
- * React Native autolinking (RCTViewManager / ReactPackage).
+ * Native modules are registered via Expo Modules autolinking on Android and
+ * React Native autolinking on iOS (RCTViewManager).
*/
/**
* Write ClerkKeychainService to Info.plist when keychainService is provided.
diff --git a/packages/expo/expo-module.config.json b/packages/expo/expo-module.config.json
index 876f466b1ad..5ed082ea351 100644
--- a/packages/expo/expo-module.config.json
+++ b/packages/expo/expo-module.config.json
@@ -1,3 +1,12 @@
{
- "platforms": ["apple"]
+ "platforms": ["apple", "android"],
+ "android": {
+ "modules": [
+ "expo.modules.clerk.ClerkExpoModule",
+ "expo.modules.clerk.ClerkAuthViewModule",
+ "expo.modules.clerk.ClerkUserProfileViewModule",
+ "expo.modules.clerk.ClerkUserButtonViewModule",
+ "expo.modules.clerk.googlesignin.ClerkGoogleSignInModule"
+ ]
+ }
}
diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo/ios/ClerkAuthNativeView.swift
new file mode 100644
index 00000000000..601d3e5252f
--- /dev/null
+++ b/packages/expo/ios/ClerkAuthNativeView.swift
@@ -0,0 +1,73 @@
+import React
+import UIKit
+
+public class ClerkAuthNativeView: ClerkNativeViewHost {
+ private var currentMode: String = "signInOrUp"
+ private var currentDismissible: Bool = true
+ private var didSendDismiss = false
+
+ @objc var onAuthEvent: RCTBubblingEventBlock?
+
+ @objc var mode: NSString? {
+ didSet {
+ let newMode = (mode as String?) ?? "signInOrUp"
+ guard newMode != currentMode else { return }
+ currentMode = newMode
+ setNeedsHostedViewUpdate()
+ }
+ }
+
+ @objc var isDismissible: NSNumber? {
+ didSet {
+ let newDismissible = isDismissible?.boolValue ?? true
+ guard newDismissible != currentDismissible else { return }
+ currentDismissible = newDismissible
+ setNeedsHostedViewUpdate()
+ }
+ }
+
+ private func sendAuthEvent(type: ClerkNativeViewEvent) {
+ onAuthEvent?(["type": type.rawValue])
+ }
+
+ private func sendDismissIfNeeded() {
+ // SwiftUI dismissals detach the hosted view without calling UIKit dismiss().
+ guard currentDismissible, !didSendDismiss else { return }
+ didSendDismiss = true
+ sendAuthEvent(type: .dismissed)
+ }
+
+ override func hostedViewDidAttachToWindow() {
+ didSendDismiss = false
+ }
+
+ override func hostedViewDidDetachFromWindow() {
+ sendDismissIfNeeded()
+ }
+
+ override func makeHostedController() -> UIViewController? {
+ guard let bridge = clerkNativeBridge else { return nil }
+
+ return bridge.makeAuthViewController(
+ mode: currentMode,
+ dismissible: currentDismissible,
+ onEvent: { [weak self] event, _ in
+ if event == .dismissed {
+ self?.sendDismissIfNeeded()
+ }
+ }
+ )
+ }
+}
+
+@objc(ClerkAuthViewManager)
+class ClerkAuthViewManager: RCTViewManager {
+
+ override static func requiresMainQueueSetup() -> Bool {
+ return true
+ }
+
+ override func view() -> UIView! {
+ return ClerkAuthNativeView()
+ }
+}
diff --git a/packages/expo/ios/ClerkAuthViewManager.m b/packages/expo/ios/ClerkAuthViewManager.m
index c5a25dd8a9b..87dbc97da29 100644
--- a/packages/expo/ios/ClerkAuthViewManager.m
+++ b/packages/expo/ios/ClerkAuthViewManager.m
@@ -3,7 +3,7 @@
@interface RCT_EXTERN_MODULE(ClerkAuthViewManager, RCTViewManager)
RCT_EXPORT_VIEW_PROPERTY(mode, NSString)
-RCT_EXPORT_VIEW_PROPERTY(isDismissable, NSNumber)
+RCT_EXPORT_VIEW_PROPERTY(isDismissible, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(onAuthEvent, RCTBubblingEventBlock)
@end
diff --git a/packages/expo/ios/ClerkAuthViewManager.swift b/packages/expo/ios/ClerkAuthViewManager.swift
deleted file mode 100644
index 0ab9629edba..00000000000
--- a/packages/expo/ios/ClerkAuthViewManager.swift
+++ /dev/null
@@ -1,13 +0,0 @@
-import React
-
-@objc(ClerkAuthViewManager)
-class ClerkAuthViewManager: RCTViewManager {
-
- override static func requiresMainQueueSetup() -> Bool {
- return true
- }
-
- override func view() -> UIView! {
- return ClerkAuthNativeView()
- }
-}
diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec
index fbd91f9a91c..d1d122982d2 100644
--- a/packages/expo/ios/ClerkExpo.podspec
+++ b/packages/expo/ios/ClerkExpo.podspec
@@ -36,11 +36,16 @@ Pod::Spec.new do |s|
}
# Only include the module files in the pod (both Swift and ObjC bridges).
- # ClerkViewFactory.swift (with views) is injected into the app target by the config plugin
+ # ClerkNativeBridge.swift is injected into the app target by the config plugin
# because it uses `import ClerkKit` which is only available via SPM in the app target.
s.source_files = "ClerkExpoModule.swift", "ClerkExpoModule.m",
- "ClerkAuthViewManager.swift", "ClerkAuthViewManager.m",
- "ClerkUserProfileViewManager.swift", "ClerkUserProfileViewManager.m"
+ "ClerkNativeViewHost.swift",
+ "ClerkAuthNativeView.swift",
+ "ClerkAuthViewManager.m",
+ "ClerkUserProfileNativeView.swift",
+ "ClerkUserProfileViewManager.m",
+ "ClerkUserButtonNativeView.swift",
+ "ClerkUserButtonViewManager.m"
install_modules_dependencies(s)
end
diff --git a/packages/expo/ios/ClerkExpoModule.m b/packages/expo/ios/ClerkExpoModule.m
index febfe003c61..a0bb2e88f08 100644
--- a/packages/expo/ios/ClerkExpoModule.m
+++ b/packages/expo/ios/ClerkExpoModule.m
@@ -8,21 +8,13 @@ @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)
RCT_EXTERN_METHOD(getClientToken:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
-RCT_EXTERN_METHOD(signOut:(RCTPromiseResolveBlock)resolve
+RCT_EXTERN_METHOD(refreshClient:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
@end
diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift
index efd1e142445..462fef7e812 100644
--- a/packages/expo/ios/ClerkExpoModule.swift
+++ b/packages/expo/ios/ClerkExpoModule.swift
@@ -1,29 +1,32 @@
// 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 ClerkNativeBridge because
+// the Clerk SDK (SPM) isn't accessible from the CocoaPods-backed React Native pod.
import UIKit
import React
-// Global registry for the Clerk view factory (set by app target at startup)
-public var clerkViewFactory: ClerkViewFactoryProtocol?
+/// Events emitted by the native view wrappers to their React Native host views.
+public enum ClerkNativeViewEvent: String {
+ /// Emitted by the Expo host view when app-owned dismissible content leaves the window.
+ case dismissed
+}
-// 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?
+// Global registry for the app-target native bridge (set by the app target at startup)
+public var clerkNativeBridge: ClerkNativeBridgeProtocol?
+// Protocol that the app target implements to provide Clerk SDK operations and SwiftUI views.
+public protocol ClerkNativeBridgeProtocol {
// 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 makeAuthViewController(mode: String, dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController?
+ func makeUserProfileViewController(dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController?
+ func makeUserButtonViewController() -> UIViewController?
// SDK operations
func configure(publishableKey: String, bearerToken: String?) async throws
func getSession() async -> [String: Any]?
func getClientToken() -> String?
- func signOut() async throws
+ func refreshClient() async throws
}
// MARK: - Module
@@ -44,7 +47,7 @@ class ClerkExpoModule: RCTEventEmitter {
}
override func supportedEvents() -> [String]! {
- return ["onAuthStateChange"]
+ return ["refreshClient"]
}
override func startObserving() {
@@ -55,30 +58,11 @@ class ClerkExpoModule: RCTEventEmitter {
ClerkExpoModule._hasListeners = false
}
- /// 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.
- static func emitAuthStateChange(type: String, sessionId: String?) {
+ /// Emits a refreshClient event to JS from anywhere in the native layer.
+ /// Used by native views to ask ClerkProvider to reload JS client state.
+ static func emitRefreshClient() {
guard _hasListeners, let instance = sharedInstance else { return }
- instance.sendEvent(withName: "onAuthStateChange", body: [
- "type": type,
- "sessionId": sessionId as Any,
- ])
- }
-
- /// 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
+ instance.sendEvent(withName: "refreshClient", body: nil)
}
// MARK: - configure
@@ -87,14 +71,14 @@ class ClerkExpoModule: RCTEventEmitter {
bearerToken: String?,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock) {
- guard let factory = clerkViewFactory else {
- reject("E_NOT_INITIALIZED", "Clerk not initialized. Make sure ClerkViewFactory is registered.", nil)
+ guard let bridge = clerkNativeBridge else {
+ reject("E_NOT_INITIALIZED", "Clerk not initialized. Make sure ClerkNativeBridge is registered.", nil)
return
}
Task {
do {
- try await factory.configure(publishableKey: publishableKey, bearerToken: bearerToken)
+ try await bridge.configure(publishableKey: publishableKey, bearerToken: bearerToken)
resolve(nil)
} catch {
reject("E_CONFIGURE_FAILED", error.localizedDescription, error)
@@ -102,84 +86,17 @@ 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,
reject: @escaping RCTPromiseRejectBlock) {
- guard let factory = clerkViewFactory else {
+ guard let bridge = clerkNativeBridge else {
resolve(nil)
return
}
Task {
- let session = await factory.getSession()
+ let session = await bridge.getSession()
resolve(session)
}
}
@@ -188,276 +105,36 @@ class ClerkExpoModule: RCTEventEmitter {
@objc func getClientToken(_ resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock) {
- guard let factory = clerkViewFactory else {
+ guard let bridge = clerkNativeBridge else {
resolve(nil)
return
}
- resolve(factory.getClientToken())
+ resolve(bridge.getClientToken())
}
- // MARK: - signOut
+ // MARK: - refreshClient
- @objc func signOut(_ resolve: @escaping RCTPromiseResolveBlock,
- reject: @escaping RCTPromiseRejectBlock) {
- guard let factory = clerkViewFactory else {
- reject("E_NOT_INITIALIZED", "Clerk not initialized", nil)
+ @objc func refreshClient(_ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard let bridge = clerkNativeBridge else {
+ resolve(nil)
return
}
Task {
do {
- try await factory.signOut()
+ try await bridge.refreshClient()
resolve(nil)
} catch {
- reject("E_SIGN_OUT_FAILED", error.localizedDescription, error)
- }
- }
- }
-}
-
-// MARK: - Inline View: ClerkAuthNativeView
-
-public class ClerkAuthNativeView: UIView {
- private var currentMode: String = "signInOrUp"
- private var currentDismissable: Bool = true
- private var hasInitialized: Bool = false
- private var authEventSent: Bool = false
- private var presentedAuthVC: UIViewController?
- private var isInvalidated: Bool = false
-
- @objc var onAuthEvent: RCTBubblingEventBlock?
-
- @objc var mode: NSString? {
- didSet {
- let newMode = (mode as String?) ?? "signInOrUp"
- guard newMode != currentMode else { return }
- currentMode = newMode
- if hasInitialized {
- dismissAuthModal()
- presentAuthModal()
- }
- }
- }
-
- @objc var isDismissable: NSNumber? {
- didSet {
- let newDismissable = isDismissable?.boolValue ?? true
- guard newDismissable != currentDismissable else { return }
- currentDismissable = newDismissable
- if hasInitialized {
- dismissAuthModal()
- presentAuthModal()
- }
- }
- }
-
- 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
- presentAuthModal()
- }
- }
-
- override public func removeFromSuperview() {
- isInvalidated = true
- dismissAuthModal()
- super.removeFromSuperview()
- }
-
- // MARK: - Modal Presentation
- //
- // The AuthView is presented as a real modal rather than embedded inline.
- // Embedding a UIHostingController as a child of a React Native view disrupts
- // ASWebAuthenticationSession callbacks during OAuth flows (e.g., SSO from the
- // forgot-password screen). Modal presentation provides an isolated SwiftUI
- // lifecycle that handles all OAuth flows correctly.
-
- private func presentAuthModal() {
- guard let factory = clerkViewFactory else { return }
-
- guard let authVC = factory.createAuthViewController(
- 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
- }
- }
- ) else { return }
-
- authVC.modalPresentationStyle = .fullScreen
- // Try to present immediately. Only wait if a previous modal is dismissing.
- presentWhenReady(authVC, attempts: 0)
- }
-
- private func dismissAuthModal() {
- presentedAuthVC?.dismiss(animated: false)
- presentedAuthVC = nil
- }
-
- /// Presents the auth view controller as soon as it's safe to do so.
- /// On initial mount this presents synchronously (no delay, no white flash).
- /// If a previous modal is still dismissing, waits for its transition coordinator
- /// to finish — no fixed delays.
- private func presentWhenReady(_ authVC: UIViewController, attempts: Int) {
- guard !isInvalidated, presentedAuthVC == nil, attempts < 30 else { return }
- guard let rootVC = Self.topViewController() else {
- DispatchQueue.main.async { [weak self] in
- self?.presentWhenReady(authVC, attempts: attempts + 1)
- }
- 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)
+ reject("E_REFRESH_CLIENT_FAILED", error.localizedDescription, error)
}
- return
}
-
- // If there's still a presented VC (no coordinator yet), wait one frame.
- if rootVC.presentedViewController != nil {
- DispatchQueue.main.async { [weak self] in
- self?.presentWhenReady(authVC, attempts: attempts + 1)
- }
- return
- }
-
- rootVC.present(authVC, animated: false)
- presentedAuthVC = authVC
}
- private static func topViewController() -> UIViewController? {
- guard let scene = UIApplication.shared.connectedScenes
- .compactMap({ $0 as? UIWindowScene })
- .first(where: { $0.activationState == .foregroundActive }),
- let rootVC = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController
- else { return nil }
-
- var top = rootVC
- while let presented = top.presentedViewController {
- top = presented
- }
- return top
- }
-
- private func sendAuthEvent(type: String, data: [String: Any]) {
- let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data()
- let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
- onAuthEvent?(["type": type, "data": jsonString])
-
- // Also emit module-level event so ClerkProvider's useNativeAuthEvents picks it up
- if type == "signInCompleted" || type == "signUpCompleted" {
- let sessionId = data["sessionId"] as? String
- ClerkExpoModule.emitAuthStateChange(type: "signedIn", sessionId: sessionId)
- }
- }
}
-// MARK: - Inline View: ClerkUserProfileNativeView
-
-public class ClerkUserProfileNativeView: UIView {
- private var hostingController: UIViewController?
- private var currentDismissable: Bool = true
- private var hasInitialized: Bool = false
-
- @objc var onProfileEvent: RCTBubblingEventBlock?
-
- @objc var isDismissable: NSNumber? {
- didSet {
- currentDismissable = isDismissable?.boolValue ?? true
- if hasInitialized { updateView() }
- }
- }
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override public func didMoveToWindow() {
- super.didMoveToWindow()
- if window != nil && !hasInitialized {
- hasInitialized = true
- updateView()
- }
- }
-
- private func updateView() {
- // Remove old hosting controller
- hostingController?.view.removeFromSuperview()
- hostingController?.removeFromParent()
- hostingController = nil
-
- 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" {
- let sessionId = data["sessionId"] as? String
- ClerkExpoModule.emitAuthStateChange(type: "signedOut", sessionId: sessionId)
- }
- }
- ) else { return }
-
- if let parentVC = findViewController() {
- parentVC.addChild(returnedController)
- returnedController.view.frame = bounds
- returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
- addSubview(returnedController.view)
- returnedController.didMove(toParent: parentVC)
- hostingController = returnedController
- } else {
- returnedController.view.frame = bounds
- returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
- addSubview(returnedController.view)
- hostingController = returnedController
- }
- }
-
- 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
- }
+/// Requests that ClerkProvider reload the JS client from native client state.
+public func emitClerkNativeRefreshClient() {
+ ClerkExpoModule.emitRefreshClient()
}
diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkNativeBridge.swift
similarity index 61%
rename from packages/expo/ios/ClerkViewFactory.swift
rename to packages/expo/ios/ClerkNativeBridge.swift
index 7e1925f41be..8df29c75fdb 100644
--- a/packages/expo/ios/ClerkViewFactory.swift
+++ b/packages/expo/ios/ClerkNativeBridge.swift
@@ -1,27 +1,32 @@
-// ClerkViewFactory - Provides Clerk view controllers to the ClerkExpo module
+// ClerkNativeBridge - Provides app-target Clerk SDK operations and SwiftUI view controllers to ClerkExpo.
// This file is injected into the app target by the config plugin.
// It uses `import ClerkKit` (SPM) which is only accessible from the app target.
import UIKit
import SwiftUI
+import Observation
import Security
import ClerkKit
import ClerkKitUI
-import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol
+import ClerkExpo // Import the pod to access ClerkNativeBridgeProtocol
-// MARK: - View Factory Implementation
+// MARK: - Native Bridge Implementation
-public final class ClerkViewFactory: ClerkViewFactoryProtocol {
- public static let shared = ClerkViewFactory()
+public final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
+ public static let shared = ClerkNativeBridge()
private static let clerkLoadMaxAttempts = 30
private static let clerkLoadIntervalNs: UInt64 = 100_000_000
private static var clerkConfigured = false
+ private static var configuredPublishableKey: String?
/// Parsed light and dark themes from Info.plist "ClerkTheme" dictionary.
var lightTheme: ClerkTheme?
var darkTheme: ClerkTheme?
+ private var clientObservationGeneration = 0
+ private var lastObservedClient: Client?
+
private enum KeychainKey {
static let jsClientJWT = "__clerk_client_jwt"
static let nativeDeviceToken = "clerkDeviceToken"
@@ -45,30 +50,85 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
return ExpoKeychain(service: service)
}
- // Register this factory with the ClerkExpo module
+ // Register this app-target bridge with the ClerkExpo module.
@MainActor public static func register() {
shared.loadThemes()
- clerkViewFactory = shared
+ clerkNativeBridge = shared
}
@MainActor
public func configure(publishableKey: String, bearerToken: String? = nil) async throws {
+ if Self.shouldReconfigure(for: publishableKey) {
+ try await Clerk.reconfigure(publishableKey: publishableKey, options: Self.makeClerkOptions())
+ Self.clerkConfigured = true
+ Self.configuredPublishableKey = publishableKey
+ startClientObserver(reset: true)
+
+ Self.syncTokenState(bearerToken: bearerToken)
+ if !(bearerToken?.isEmpty ?? true) {
+ _ = try? await Clerk.shared.refreshClient()
+ }
+
+ await Self.waitForLoadedSession()
+ return
+ }
+
Self.syncTokenState(bearerToken: bearerToken)
// If already configured with a new bearer token, refresh the client
// to pick up the session associated with the device token we just wrote.
- // Clerk.configure() is a no-op on subsequent calls, so we use refreshClient().
+ // Clerk.configure() is idempotent for the same publishable key, so use refreshClient().
if Self.shouldRefreshConfiguredClient(for: bearerToken) {
+ startClientObserver()
_ = try? await Clerk.shared.refreshClient()
+ await Self.waitForLoadedSession()
+ return
+ }
+
+ if Self.clerkConfigured {
+ startClientObserver()
return
}
Self.clerkConfigured = true
+ Self.configuredPublishableKey = publishableKey
Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions())
+ startClientObserver()
await Self.waitForLoadedSession()
}
+ @MainActor
+ private func startClientObserver(reset: Bool = false) {
+ guard reset || clientObservationGeneration == 0 else { return }
+
+ clientObservationGeneration += 1
+ let generation = clientObservationGeneration
+ lastObservedClient = Clerk.shared.client
+ observeClient(generation: generation)
+ }
+
+ @MainActor
+ private func observeClient(generation: Int) {
+ withObservationTracking {
+ _ = Clerk.shared.client
+ } onChange: { [weak self] in
+ Task { @MainActor [weak self] in
+ await Task.yield()
+
+ guard let self, generation == self.clientObservationGeneration else { return }
+
+ let newClient = Clerk.shared.client
+ if newClient != self.lastObservedClient {
+ self.lastObservedClient = newClient
+ emitClerkNativeRefreshClient()
+ }
+
+ self.observeClient(generation: generation)
+ }
+ }
+ }
+
private static func syncTokenState(bearerToken: String?) {
// Sync JS SDK's client token to native keychain so both SDKs share the same client.
// This handles the case where the user signed in via JS SDK but the native SDK
@@ -95,6 +155,11 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
clerkConfigured && !(bearerToken?.isEmpty ?? true)
}
+ private static func shouldReconfigure(for publishableKey: String) -> Bool {
+ guard clerkConfigured, let configuredPublishableKey else { return false }
+ return configuredPublishableKey != publishableKey
+ }
+
private static func makeClerkOptions() -> Clerk.Options {
guard let service = keychainService else {
return .init()
@@ -107,7 +172,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(
+ public func makeAuthViewController(
mode: String,
- dismissable: Bool,
- onEvent: @escaping (String, [String: Any]) -> Void
+ dismissible: Bool,
+ onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void
) -> UIViewController? {
makeHostingController(
rootView: ClerkInlineAuthWrapperView(
mode: Self.authMode(from: mode),
- dismissable: dismissable,
+ dismissible: dismissible,
lightTheme: lightTheme,
- darkTheme: darkTheme,
- onEvent: onEvent
- )
+ darkTheme: darkTheme
+ ),
+ onDismiss: dismissible ? { onEvent(.dismissed, [:]) } : nil
)
}
- public func createUserProfileView(
- dismissable: Bool,
- onEvent: @escaping (String, [String: Any]) -> Void
+ public func makeUserProfileViewController(
+ dismissible: Bool,
+ onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void
) -> UIViewController? {
makeHostingController(
rootView: ClerkInlineProfileWrapperView(
- dismissable: dismissable,
+ dismissible: dismissible,
+ lightTheme: lightTheme,
+ darkTheme: darkTheme
+ ),
+ onDismiss: dismissible ? { onEvent(.dismissed, [:]) } : nil
+ )
+ }
+
+ public func makeUserButtonViewController() -> UIViewController? {
+ makeHostingController(
+ rootView: ClerkInlineUserButtonWrapperView(
lightTheme: lightTheme,
- darkTheme: darkTheme,
- onEvent: onEvent
+ darkTheme: darkTheme
)
)
}
@@ -218,14 +264,10 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
}
@MainActor
- public func signOut() async throws {
- if Self.clerkConfigured {
- defer { Clerk.clearAllKeychainItems() }
- if let sessionId = Clerk.shared.session?.id {
- try await Clerk.shared.auth.signOut(sessionId: sessionId)
- }
- }
- Self.clerkConfigured = false
+ public func refreshClient() async throws {
+ guard Self.clerkConfigured else { return }
+ _ = try await Clerk.shared.refreshClient()
+ await Self.waitForLoadedSession()
}
private static func authMode(from mode: String) -> AuthView.Mode {
@@ -324,8 +366,11 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
return ClerkTheme.Design(borderRadius: CGFloat(radius))
}
- private func makeHostingController(rootView: Content) -> UIViewController {
- let hostingController = UIHostingController(rootView: rootView)
+ private func makeHostingController(
+ rootView: Content,
+ onDismiss: (() -> Void)? = nil
+ ) -> UIViewController {
+ let hostingController = ClerkNativeHostingController(rootView: rootView, onDismiss: onDismiss)
hostingController.view.backgroundColor = .clear
return hostingController
}
@@ -420,167 +465,27 @@ private struct ExpoKeychain {
}
}
-// MARK: - Auth View Controller Wrapper
-
-class ClerkAuthWrapperViewController: UIHostingController {
- private let completion: (Result<[String: Any], Error>) -> Void
- private var authEventTask: Task?
- private var completionCalled = false
-
- init(mode: AuthView.Mode, dismissable: Bool, lightTheme: ClerkTheme?, darkTheme: ClerkTheme?, completion: @escaping (Result<[String: Any], Error>) -> Void) {
- self.completion = completion
- let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable, lightTheme: lightTheme, darkTheme: darkTheme)
- super.init(rootView: view)
- self.modalPresentationStyle = .fullScreen
- subscribeToAuthEvents()
- }
-
- @MainActor required dynamic init?(coder aDecoder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- deinit {
- authEventTask?.cancel()
- }
-
- override func viewDidDisappear(_ animated: Bool) {
- super.viewDidDisappear(animated)
- if isBeingDismissed {
- // Check if auth completed (session exists) vs user cancelled
- if let session = Clerk.shared.session, session.id != initialSessionId {
- completeOnce(.success(["sessionId": session.id, "type": "signIn"]))
- } else {
- completeOnce(.success(["cancelled": true]))
- }
- }
- }
-
- private func completeOnce(_ result: Result<[String: Any], Error>) {
- guard !completionCalled else { return }
- completionCalled = true
- completion(result)
- }
-
- private var initialSessionId: String? = Clerk.shared.session?.id
-
- private func subscribeToAuthEvents() {
- authEventTask = Task { @MainActor [weak self] in
- for await event in Clerk.shared.auth.events {
- guard let self = self, !self.completionCalled else { return }
- switch event {
- case .signInCompleted(let signIn):
- let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id
- if let sessionId, sessionId != self.initialSessionId {
- self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"]))
- self.dismiss(animated: true)
- }
- case .signUpCompleted(let signUp):
- let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id
- if let sessionId, sessionId != self.initialSessionId {
- self.completeOnce(.success(["sessionId": sessionId, "type": "signUp"]))
- self.dismiss(animated: true)
- }
- case .sessionChanged(_, let newSession):
- if let sessionId = newSession?.id, sessionId != self.initialSessionId {
- self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"]))
- self.dismiss(animated: true)
- }
- default:
- break
- }
- }
- }
- }
-}
+// MARK: - Inline User Button Wrapper (for embedded rendering)
-struct ClerkAuthWrapperView: View {
- let mode: AuthView.Mode
- let dismissable: Bool
+struct ClerkInlineUserButtonWrapperView: View {
let lightTheme: ClerkTheme?
let darkTheme: ClerkTheme?
@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]))
- }
- }
-
- 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
- }
+ let themedView = Group {
+ if let theme {
+ view.environment(\.clerkTheme, theme)
+ } else {
+ view
}
}
- }
-}
-
-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
- }
+ themedView
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
@@ -588,25 +493,14 @@ struct ClerkProfileWrapperView: View {
struct ClerkInlineAuthWrapperView: View {
let mode: AuthView.Mode
- let dismissable: Bool
+ let dismissible: Bool
let lightTheme: ClerkTheme?
let darkTheme: ClerkTheme?
- let onEvent: (String, [String: Any]) -> Void
-
- // Track initial session to detect new sign-ins (same approach as Android)
- @State private var initialSessionId: String? = Clerk.shared.session?.id
- @State private var eventSent = false
@Environment(\.colorScheme) private var colorScheme
- private func sendAuthCompleted(sessionId: String, type: String) {
- guard !eventSent, sessionId != initialSessionId else { return }
- eventSent = true
- onEvent(type, ["sessionId": sessionId, "type": type == "signUpCompleted" ? "signUp" : "signIn"])
- }
-
private var themedAuthView: some View {
- let view = AuthView(mode: mode, isDismissable: dismissable)
+ let view = AuthView(mode: mode, isDismissable: dismissible)
.environment(Clerk.shared)
let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
return Group {
@@ -620,45 +514,45 @@ struct ClerkInlineAuthWrapperView: View {
var body: some View {
themedAuthView
- // Primary detection: observe Clerk.shared.session directly (matches Android's sessionFlow approach).
- // This is more reliable than auth.events which may not emit for inline AuthView sign-ins.
- .onChange(of: Clerk.shared.session?.id) { _, newSessionId in
- guard let sessionId = newSessionId else { return }
- sendAuthCompleted(sessionId: sessionId, type: "signInCompleted")
- }
- // Fallback: also listen to auth.events for signUp events and edge cases
- .task {
- for await event in Clerk.shared.auth.events {
- guard !eventSent else { continue }
- switch event {
- case .signInCompleted(let signIn):
- let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id
- if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") }
- case .signUpCompleted(let signUp):
- let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id
- if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signUpCompleted") }
- case .sessionChanged(_, let newSession):
- if let sessionId = newSession?.id { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") }
- default:
- break
- }
- }
- }
+ }
+}
+
+private final class ClerkNativeHostingController: UIHostingController {
+ private let onDismiss: (() -> Void)?
+ private var didSendDismiss = false
+
+ init(rootView: Content, onDismiss: (() -> Void)? = nil) {
+ self.onDismiss = onDismiss
+ super.init(rootView: rootView)
+ }
+
+ @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
+ sendDismissIfNeeded()
+ super.dismiss(animated: flag, completion: completion)
+ }
+
+ private func sendDismissIfNeeded() {
+ guard !didSendDismiss else { return }
+ didSendDismiss = true
+ onDismiss?()
}
}
// MARK: - Inline Profile View Wrapper (for embedded rendering)
struct ClerkInlineProfileWrapperView: View {
- let dismissable: Bool
+ let dismissible: Bool
let lightTheme: ClerkTheme?
let darkTheme: ClerkTheme?
- let onEvent: (String, [String: Any]) -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
- let view = UserProfileView(isDismissable: dismissable)
+ let view = UserProfileView(isDismissable: dismissible)
.environment(Clerk.shared)
let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
let themedView = Group {
@@ -669,15 +563,5 @@ struct ClerkInlineProfileWrapperView: View {
}
}
themedView
- .task {
- for await event in Clerk.shared.auth.events {
- switch event {
- case .signedOut(let session):
- onEvent("signedOut", ["sessionId": session.id])
- default:
- break
- }
- }
- }
}
}
diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo/ios/ClerkNativeViewHost.swift
new file mode 100644
index 00000000000..58385486eb3
--- /dev/null
+++ b/packages/expo/ios/ClerkNativeViewHost.swift
@@ -0,0 +1,113 @@
+import UIKit
+
+public class ClerkNativeViewHost: UIView {
+ private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self)
+ private var hasInitialized: Bool = false
+
+ override public init(frame: CGRect) {
+ super.init(frame: frame)
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override public func didMoveToWindow() {
+ super.didMoveToWindow()
+
+ guard window != nil else {
+ if hasInitialized {
+ hostedViewDidDetachFromWindow()
+ }
+ hostingCoordinator.detach()
+ hasInitialized = false
+ return
+ }
+
+ guard !hasInitialized else { return }
+ hasInitialized = true
+ hostedViewDidAttachToWindow()
+ updateHostedView()
+ }
+
+ override public func layoutSubviews() {
+ super.layoutSubviews()
+ hostingCoordinator.layout()
+ }
+
+ func setNeedsHostedViewUpdate() {
+ guard hasInitialized else { return }
+ updateHostedView()
+ }
+
+ func makeHostedController() -> UIViewController? {
+ nil
+ }
+
+ // Subclasses can observe attach/detach without making this host know about their RN event props.
+ func hostedViewDidAttachToWindow() {}
+
+ func hostedViewDidDetachFromWindow() {}
+
+ private func updateHostedView() {
+ guard let controller = makeHostedController() else { return }
+ hostingCoordinator.attach(controller)
+ }
+}
+
+private final class ClerkNativeHostingCoordinator {
+ private weak var containerView: UIView?
+ private var hostingController: UIViewController?
+
+ init(containerView: UIView) {
+ self.containerView = containerView
+ }
+
+ func attach(_ controller: UIViewController) {
+ detach()
+
+ guard let containerView else { return }
+
+ controller.view.frame = containerView.bounds
+ controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+
+ if let parentVC = findViewController(from: containerView) {
+ parentVC.addChild(controller)
+ containerView.addSubview(controller.view)
+ controller.didMove(toParent: parentVC)
+ } else {
+ containerView.addSubview(controller.view)
+ }
+
+ hostingController = controller
+ }
+
+ func detach() {
+ guard let controller = hostingController else { return }
+
+ if controller.parent != nil {
+ controller.willMove(toParent: nil)
+ }
+ controller.view.removeFromSuperview()
+ if controller.parent != nil {
+ controller.removeFromParent()
+ }
+ hostingController = nil
+ }
+
+ func layout() {
+ guard let containerView else { return }
+ hostingController?.view.frame = containerView.bounds
+ }
+
+ private func findViewController(from view: UIView) -> UIViewController? {
+ var responder: UIResponder? = view
+ while let nextResponder = responder?.next {
+ if let vc = nextResponder as? UIViewController {
+ return vc
+ }
+ responder = nextResponder
+ }
+ return nil
+ }
+}
diff --git a/packages/expo/ios/ClerkUserButtonNativeView.swift b/packages/expo/ios/ClerkUserButtonNativeView.swift
new file mode 100644
index 00000000000..9fe39622752
--- /dev/null
+++ b/packages/expo/ios/ClerkUserButtonNativeView.swift
@@ -0,0 +1,22 @@
+import React
+import UIKit
+
+public class ClerkUserButtonNativeView: ClerkNativeViewHost {
+ override func makeHostedController() -> UIViewController? {
+ guard let bridge = clerkNativeBridge else { return nil }
+
+ return bridge.makeUserButtonViewController()
+ }
+}
+
+@objc(ClerkUserButtonViewManager)
+class ClerkUserButtonViewManager: RCTViewManager {
+
+ override static func requiresMainQueueSetup() -> Bool {
+ return true
+ }
+
+ override func view() -> UIView! {
+ return ClerkUserButtonNativeView()
+ }
+}
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/ClerkUserProfileNativeView.swift b/packages/expo/ios/ClerkUserProfileNativeView.swift
new file mode 100644
index 00000000000..412a3940ad9
--- /dev/null
+++ b/packages/expo/ios/ClerkUserProfileNativeView.swift
@@ -0,0 +1,62 @@
+import React
+import UIKit
+
+public class ClerkUserProfileNativeView: ClerkNativeViewHost {
+ private var currentDismissible: Bool = true
+ private var didSendDismiss = false
+
+ @objc var onProfileEvent: RCTBubblingEventBlock?
+
+ @objc var isDismissible: NSNumber? {
+ didSet {
+ let newDismissible = isDismissible?.boolValue ?? true
+ guard newDismissible != currentDismissible else { return }
+ currentDismissible = newDismissible
+ setNeedsHostedViewUpdate()
+ }
+ }
+
+ private func sendProfileEvent(type: ClerkNativeViewEvent) {
+ onProfileEvent?(["type": type.rawValue])
+ }
+
+ private func sendDismissIfNeeded() {
+ // SwiftUI dismissals detach the hosted view without calling UIKit dismiss().
+ guard currentDismissible, !didSendDismiss else { return }
+ didSendDismiss = true
+ sendProfileEvent(type: .dismissed)
+ }
+
+ override func hostedViewDidAttachToWindow() {
+ didSendDismiss = false
+ }
+
+ override func hostedViewDidDetachFromWindow() {
+ sendDismissIfNeeded()
+ }
+
+ override func makeHostedController() -> UIViewController? {
+ guard let bridge = clerkNativeBridge else { return nil }
+
+ return bridge.makeUserProfileViewController(
+ dismissible: currentDismissible,
+ onEvent: { [weak self] event, _ in
+ if event == .dismissed {
+ self?.sendDismissIfNeeded()
+ }
+ }
+ )
+ }
+}
+
+@objc(ClerkUserProfileViewManager)
+class ClerkUserProfileViewManager: RCTViewManager {
+
+ override static func requiresMainQueueSetup() -> Bool {
+ return true
+ }
+
+ override func view() -> UIView! {
+ return ClerkUserProfileNativeView()
+ }
+}
diff --git a/packages/expo/ios/ClerkUserProfileViewManager.m b/packages/expo/ios/ClerkUserProfileViewManager.m
index 35eaf720ed9..ee06c66a125 100644
--- a/packages/expo/ios/ClerkUserProfileViewManager.m
+++ b/packages/expo/ios/ClerkUserProfileViewManager.m
@@ -2,7 +2,7 @@
@interface RCT_EXTERN_MODULE(ClerkUserProfileViewManager, RCTViewManager)
-RCT_EXPORT_VIEW_PROPERTY(isDismissable, NSNumber)
+RCT_EXPORT_VIEW_PROPERTY(isDismissible, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(onProfileEvent, RCTBubblingEventBlock)
@end
diff --git a/packages/expo/ios/ClerkUserProfileViewManager.swift b/packages/expo/ios/ClerkUserProfileViewManager.swift
deleted file mode 100644
index b8e9c269f6a..00000000000
--- a/packages/expo/ios/ClerkUserProfileViewManager.swift
+++ /dev/null
@@ -1,13 +0,0 @@
-import React
-
-@objc(ClerkUserProfileViewManager)
-class ClerkUserProfileViewManager: RCTViewManager {
-
- override static func requiresMainQueueSetup() -> Bool {
- return true
- }
-
- override func view() -> UIView! {
- return ClerkUserProfileNativeView()
- }
-}
diff --git a/packages/expo/package.json b/packages/expo/package.json
index 647eacfdd6b..c045ebdc81a 100644
--- a/packages/expo/package.json
+++ b/packages/expo/package.json
@@ -183,9 +183,6 @@
"codegenConfig": {
"name": "ClerkExpoSpec",
"type": "all",
- "jsSrcsDir": "src/specs",
- "android": {
- "javaPackageName": "expo.modules.clerk"
- }
+ "jsSrcsDir": "src/specs"
}
}
diff --git a/packages/expo/react-native.config.js b/packages/expo/react-native.config.js
index 84cec6c149d..b711c2c04f1 100644
--- a/packages/expo/react-native.config.js
+++ b/packages/expo/react-native.config.js
@@ -2,10 +2,7 @@ module.exports = {
dependency: {
platforms: {
ios: {},
- android: {
- packageImportPath: 'import expo.modules.clerk.ClerkPackage;',
- packageInstance: 'new ClerkPackage()',
- },
+ android: null,
},
},
};
diff --git a/packages/expo/src/cache/types.ts b/packages/expo/src/cache/types.ts
index 30aa2ae124c..f2da7c72187 100644
--- a/packages/expo/src/cache/types.ts
+++ b/packages/expo/src/cache/types.ts
@@ -3,7 +3,7 @@ import type { IStorage } from '../provider/singleton/types';
export interface TokenCache {
getToken: (key: string) => Promise;
saveToken: (key: string, token: string) => Promise;
- clearToken?: (key: string) => void;
+ clearToken?: (key: string) => void | Promise;
}
export interface ResourceCache {
diff --git a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
index c297713a801..ca790a86266 100644
--- a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
+++ b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
@@ -52,7 +52,7 @@ vi.mock('../../specs/NativeClerkModule', () => {
configure: vi.fn(),
getSession: vi.fn(),
getClientToken: vi.fn(),
- signOut: vi.fn(),
+ refreshClient: vi.fn(),
},
};
});
diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts
index 6c7f22b4d43..8dab9679b19 100644
--- a/packages/expo/src/hooks/index.ts
+++ b/packages/expo/src/hooks/index.ts
@@ -18,5 +18,3 @@ export * from './useSSO';
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
deleted file mode 100644
index 7a18e4df16f..00000000000
--- a/packages/expo/src/hooks/useNativeAuthEvents.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { useEffect, useState } from 'react';
-import { NativeEventEmitter } from 'react-native';
-
-import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module';
-
-/**
- * Auth state change event from native SDK
- */
-export interface NativeAuthStateEvent {
- type: 'signedIn' | 'signedOut';
- sessionId: string | null;
-}
-
-export interface UseNativeAuthEventsReturn {
- /**
- * The latest auth state event from the native SDK.
- * Will be null until an event is received.
- */
- nativeAuthState: NativeAuthStateEvent | null;
-
- /**
- * Whether native event listening is supported (plugin installed)
- */
- isSupported: boolean;
-}
-
-/**
- * Hook to listen for auth state change events from the native Clerk SDK.
- *
- * 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)
- *
- * @example
- * ```tsx
- * import { useNativeAuthEvents } from '@clerk/expo';
- *
- * function MyComponent() {
- * const { nativeAuthState, isSupported } = useNativeAuthEvents();
- *
- * useEffect(() => {
- * if (nativeAuthState?.type === 'signedIn') {
- * console.log('User signed in via native UI');
- * } else if (nativeAuthState?.type === 'signedOut') {
- * console.log('User signed out via native UI');
- * }
- * }, [nativeAuthState]);
- *
- * return ;
- * }
- * ```
- */
-export function useNativeAuthEvents(): UseNativeAuthEventsReturn {
- const [nativeAuthState, setNativeAuthState] = useState(null);
-
- useEffect(() => {
- if (!isNativeSupported || !ClerkExpo) {
- return;
- }
-
- let subscription: { remove: () => void } | null = null;
-
- try {
- const eventEmitter = new NativeEventEmitter(ClerkExpo as any);
-
- subscription = eventEmitter.addListener('onAuthStateChange', (event: NativeAuthStateEvent) => {
- if (__DEV__) {
- console.log('[useNativeAuthEvents] onAuthStateChange:', JSON.stringify(event));
- }
- setNativeAuthState(event);
- });
- } catch (error) {
- if (__DEV__) {
- console.error('[useNativeAuthEvents] Failed to set up event listener:', error);
- }
- }
-
- return () => {
- subscription?.remove();
- };
- }, []);
-
- return {
- nativeAuthState,
- isSupported: isNativeSupported && !!ClerkExpo,
- };
-}
diff --git a/packages/expo/src/hooks/useNativeClientEvents.ts b/packages/expo/src/hooks/useNativeClientEvents.ts
new file mode 100644
index 00000000000..03b4fcfd3e8
--- /dev/null
+++ b/packages/expo/src/hooks/useNativeClientEvents.ts
@@ -0,0 +1,61 @@
+import { useEffect, useState } from 'react';
+import { NativeEventEmitter, Platform } from 'react-native';
+
+import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module';
+
+/**
+ * Local marker for a native client event.
+ */
+interface NativeClientEvent {
+ issuedAt: number;
+}
+
+interface UseNativeClientEventsReturn {
+ nativeClientEvent: NativeClientEvent | null;
+}
+
+type RefreshClientEventSubscription = {
+ remove: () => void;
+};
+
+type RefreshClientEventEmitter = {
+ addListener: (eventName: 'refreshClient', listener: () => void) => RefreshClientEventSubscription;
+};
+
+/**
+ * Listens for native client events that should sync JS client state.
+ */
+export function useNativeClientEvents(): UseNativeClientEventsReturn {
+ const [nativeClientEvent, setNativeClientEvent] = useState(null);
+
+ useEffect(() => {
+ if (!isNativeSupported || !ClerkExpo) {
+ return;
+ }
+
+ let subscription: { remove: () => void } | null = null;
+
+ try {
+ const eventEmitter: RefreshClientEventEmitter =
+ Platform.OS === 'android'
+ ? (ClerkExpo as RefreshClientEventEmitter)
+ : (new NativeEventEmitter(ClerkExpo) as RefreshClientEventEmitter);
+
+ subscription = eventEmitter.addListener('refreshClient', () => {
+ setNativeClientEvent({ issuedAt: Date.now() });
+ });
+ } catch (error) {
+ if (__DEV__) {
+ console.error('[useNativeClientEvents] Failed to set up event listener:', error);
+ }
+ }
+
+ return () => {
+ subscription?.remove();
+ };
+ }, []);
+
+ return {
+ nativeClientEvent,
+ };
+}
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..e10997f9dff 100644
--- a/packages/expo/src/native/AuthView.tsx
+++ b/packages/expo/src/native/AuthView.tsx
@@ -1,59 +1,11 @@
-import { ClerkRuntimeError } from '@clerk/shared/error';
-import { useCallback, useRef } from 'react';
+import { type ComponentProps, type ReactElement, 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');
- }
- }
-}
+type AuthNativeEvent = Parameters['onAuthEvent']>>[0];
/**
* A pre-built native authentication component that handles sign-in and sign-up flows.
@@ -63,7 +15,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 +36,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', isDismissible = true, onDismiss }: AuthViewProps): ReactElement {
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: AuthNativeEvent) => {
+ if (event.nativeEvent.type === 'dismissed') {
+ onDismiss?.();
}
},
- [syncSession],
+ [onDismiss],
);
if (!isNativeSupported || !NativeClerkAuthView) {
@@ -144,7 +62,7 @@ export function AuthView({ mode = 'signInOrUp', isDismissable = false }: AuthVie
);
diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts
index 2f316488827..70b0c83fca4 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 {
/**
@@ -34,7 +34,15 @@ export interface AuthViewProps {
* When `false`, the user must complete authentication to close the view.
* Use this for flows where authentication is required to proceed.
*
- * @default false
+ * @default true
*/
- isDismissable?: boolean;
+ isDismissible?: boolean;
+
+ /**
+ * Called when the native authentication view requests dismissal.
+ *
+ * This fires when the user dismisses the view, or when the native auth flow
+ * finishes and the app-owned presentation should close.
+ */
+ 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..1263569d5c2 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,17 +12,22 @@ 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
+ * @default true
*/
- isDismissable?: boolean;
+ isDismissible?: boolean;
/**
* 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({ isDismissible = true, 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) {
@@ -104,7 +85,7 @@ export function UserProfileView({ isDismissable = false, style }: UserProfileVie
return (
);
diff --git a/packages/expo/src/native/__tests__/AuthView.test.tsx b/packages/expo/src/native/__tests__/AuthView.test.tsx
new file mode 100644
index 00000000000..4f7c1582b74
--- /dev/null
+++ b/packages/expo/src/native/__tests__/AuthView.test.tsx
@@ -0,0 +1,43 @@
+import { render } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, test, vi } from 'vitest';
+
+import { AuthView } from '../AuthView';
+
+const mocks = vi.hoisted(() => {
+ return {
+ NativeClerkAuthView: vi.fn(() => null),
+ };
+});
+
+vi.mock('../../specs/NativeClerkAuthView', () => {
+ return {
+ default: mocks.NativeClerkAuthView,
+ };
+});
+
+vi.mock('../../utils/native-module', () => {
+ return {
+ isNativeSupported: true,
+ };
+});
+
+vi.mock('react-native', () => {
+ return {
+ Text: ({ children }: { children?: React.ReactNode }) => React.createElement('span', null, children),
+ View: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children),
+ };
+});
+
+describe('AuthView', () => {
+ test('calls onDismiss when the native auth view emits dismissed', () => {
+ const onDismiss = vi.fn();
+
+ render();
+
+ const props = mocks.NativeClerkAuthView.mock.calls[0]?.[0];
+ props.onAuthEvent({ nativeEvent: { type: 'dismissed' } });
+
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+});
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/plugin/withClerkExpo.ts b/packages/expo/src/plugin/withClerkExpo.ts
index 0cddd5ff281..4f26423bc37 100644
--- a/packages/expo/src/plugin/withClerkExpo.ts
+++ b/packages/expo/src/plugin/withClerkExpo.ts
@@ -82,8 +82,8 @@ const withClerkGoogleSignIn: ConfigPlugin = config => {
* 1. Configures iOS URL scheme for Google Sign-In (if env var is set)
* 2. Adds Android packaging exclusions to resolve dependency conflicts
*
- * Native modules are registered via react-native.config.js and standard
- * React Native autolinking (RCTViewManager / ReactPackage).
+ * Native modules are registered via Expo Modules autolinking on Android and
+ * React Native autolinking on iOS (RCTViewManager).
*/
const withClerkExpo: ConfigPlugin = config => {
config = withClerkGoogleSignIn(config);
diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx
index 3c683be6c26..f7d0f158614 100644
--- a/packages/expo/src/provider/ClerkProvider.tsx
+++ b/packages/expo/src/provider/ClerkProvider.tsx
@@ -1,14 +1,13 @@
import '../polyfills';
import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react';
-import { useAuth } from '@clerk/react';
import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal';
-import { useEffect, useRef } from 'react';
+import { type MutableRefObject, useEffect, useRef } from 'react';
import { Platform } from 'react-native';
import type { TokenCache } from '../cache/types';
import { CLERK_CLIENT_JWT_KEY } from '../constants';
-import { useNativeAuthEvents } from '../hooks/useNativeAuthEvents';
+import { useNativeClientEvents } from '../hooks/useNativeClientEvents';
import NativeClerkModule from '../specs/NativeClerkModule';
import { tokenCache as defaultTokenCache } from '../token-cache';
import { isNative, isWeb } from '../utils/runtime';
@@ -54,138 +53,163 @@ const SDK_METADATA = {
version: PACKAGE_VERSION,
};
+type SyncableClerkInstance = {
+ addListener?: (listener: (payload?: unknown) => void, options?: { skipInitialEmit?: boolean }) => () => void;
+ addOnLoaded?: (listener: () => void) => void;
+ client?: { lastActiveSessionId?: string | null } | null;
+ loaded?: boolean;
+ session?: { id?: string | null } | null;
+ setActive?: (params: { session: string }) => void | Promise;
+ __internal_reloadInitialResources?: () => void | Promise;
+};
+
+async function waitForNativeClientToken(): Promise {
+ const ClerkExpo = NativeClerkModule;
+ if (!ClerkExpo?.getClientToken) {
+ return null;
+ }
+
+ const maxAttempts = 30;
+ const intervalMs = 100;
+
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ const nativeClientToken = await ClerkExpo.getClientToken();
+ if (nativeClientToken) {
+ return nativeClientToken;
+ }
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
+ }
+
+ return null;
+}
+
+async function syncClientTokenToCache(tokenCache: TokenCache | undefined, clientToken: string | null): Promise {
+ if (clientToken) {
+ await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, clientToken);
+ } else {
+ await tokenCache?.clearToken?.(CLERK_CLIENT_JWT_KEY);
+ }
+}
+
+async function syncNativeClientToJs({
+ clerkInstance,
+ tokenCache,
+}: {
+ clerkInstance: SyncableClerkInstance;
+ tokenCache: TokenCache | undefined;
+}): Promise {
+ const nativeClientToken = await waitForNativeClientToken();
+ const effectiveTokenCache = tokenCache ?? defaultTokenCache;
+
+ await syncClientTokenToCache(effectiveTokenCache, nativeClientToken);
+ if (typeof clerkInstance.__internal_reloadInitialResources === 'function') {
+ await clerkInstance.__internal_reloadInitialResources();
+ }
+
+ const nativeActiveSessionId = clerkInstance.client?.lastActiveSessionId;
+ const jsActiveSessionId = clerkInstance.session?.id;
+
+ if (
+ nativeActiveSessionId &&
+ nativeActiveSessionId !== jsActiveSessionId &&
+ typeof clerkInstance.setActive === 'function'
+ ) {
+ await clerkInstance.setActive({ session: nativeActiveSessionId });
+ }
+}
+
/**
- * Syncs JS SDK auth state to the native Clerk SDK.
- *
- * When a user authenticates via the JS SDK (custom sign-in forms, useSignIn, etc.)
- * rather than through native ``, the native SDK doesn't know about the
- * session. This component watches for JS auth state changes and pushes the bearer
- * token to the native SDK so native components (UserButton, UserProfileView) work.
+ * Syncs JS SDK client changes to the native Clerk SDK so native components
+ * (UserButton, UserProfileView) stay in sync after JS-owned resource changes.
*
- * Must be rendered inside `ClerkReactProvider` so `useAuth()` has access to context.
+ * Must be rendered inside `ClerkReactProvider` so the Clerk instance has loaded
+ * resources to emit.
*/
-function NativeSessionSync({
+function NativeClientSync({
+ clerkInstance,
+ isSyncingNativeClientToJsRef,
publishableKey,
tokenCache,
}: {
+ clerkInstance: SyncableClerkInstance | null | undefined;
+ isSyncingNativeClientToJsRef: MutableRefObject;
publishableKey: string;
tokenCache: TokenCache | undefined;
-}) {
- const { isSignedIn, isLoaded } = useAuth();
- const hasSyncedRef = useRef(false);
+}): null {
+ const isRefreshingNativeFromJsRef = useRef(false);
// Use the provided tokenCache, falling back to the default SecureStore cache
const effectiveTokenCache = tokenCache ?? defaultTokenCache;
useEffect(() => {
- 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);
- }
- });
- }
- }
-
+ if (!clerkInstance || typeof clerkInstance.addListener !== 'function') {
return;
}
- if (hasSyncedRef.current) {
- return;
- }
-
- const syncToNative = async () => {
- try {
- const ClerkExpo = NativeClerkModule;
- if (!ClerkExpo?.configure || !ClerkExpo?.getSession) {
+ return clerkInstance.addListener(
+ () => {
+ if (isSyncingNativeClientToJsRef.current || isRefreshingNativeFromJsRef.current) {
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);
+ isRefreshingNativeFromJsRef.current = true;
- if (hasNativeSession) {
- hasSyncedRef.current = true;
- return;
- }
+ const refreshNativeFromJsClient = async (): Promise => {
+ const ClerkExpo = NativeClerkModule;
+ if (!ClerkExpo) {
+ return;
+ }
- // Read the JS SDK's client JWT and push it to the native SDK
- const bearerToken = (await effectiveTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null;
- if (bearerToken) {
- await ClerkExpo.configure(publishableKey, bearerToken);
- hasSyncedRef.current = true;
- }
- } catch (error) {
- if (__DEV__) {
- console.warn('[NativeSessionSync] Failed to sync JS session to native:', error);
- }
- }
- };
+ const bearerToken = (await effectiveTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null;
+ if (bearerToken) {
+ // configure writes the token and refreshes native client state.
+ await ClerkExpo.configure(publishableKey, bearerToken);
+ } else {
+ // No token to push; ask native to reload its current client.
+ await ClerkExpo.refreshClient();
+ }
+ };
- void syncToNative();
- }, [isSignedIn, isLoaded, publishableKey, effectiveTokenCache]);
+ void refreshNativeFromJsClient()
+ .catch((error: unknown) => {
+ if (__DEV__) {
+ console.warn('[NativeClientSync] Failed to refresh native client from JS client change:', error);
+ }
+ })
+ .finally(() => {
+ isRefreshingNativeFromJsRef.current = false;
+ });
+ },
+ { skipInitialEmit: true },
+ );
+ }, [clerkInstance, effectiveTokenCache, isSyncingNativeClientToJsRef, publishableKey]);
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({
+ isSyncingNativeClientToJsRef,
+ publishableKey,
+ tokenCache,
+ clerkInstance,
+}: {
+ isSyncingNativeClientToJsRef: MutableRefObject;
+ publishableKey: string;
+ tokenCache: TokenCache | undefined;
+ clerkInstance: SyncableClerkInstance | null | undefined;
+}) {
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 () => {
@@ -193,8 +217,6 @@ export function ClerkProvider(props: ClerkProviderProps(props: ClerkProviderProps setTimeout(resolve, POLL_INTERVAL_MS));
- }
+ await ClerkExpo.configure(publishableKey, bearerToken);
if (!isMountedRef.current) {
return;
}
- if (sessionId && clerkInstance) {
- pendingNativeSessionRef.current = sessionId;
-
- // Wait for clerk to be loaded before syncing
- const clerkAny = clerkInstance as any;
-
+ if (clerkInstance) {
const waitForLoad = (): Promise => {
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');
@@ -268,25 +255,16 @@ export function ClerkProvider(props: ClerkProviderProps s.id === pendingSession,
- );
- if (!sessionInClient && typeof clerkAny.__internal_reloadInitialResources === 'function') {
- await clerkAny.__internal_reloadInitialResources();
- }
-
+ isSyncingNativeClientToJsRef.current = true;
try {
- await clerkInstance.setActive({ session: pendingSession });
- } catch (err) {
- if (__DEV__) {
- console.error(`[ClerkProvider] Failed to sync native session:`, err);
- }
+ await syncNativeClientToJs({
+ clerkInstance,
+ tokenCache,
+ });
+ } finally {
+ isSyncingNativeClientToJsRef.current = false;
}
}
}
@@ -314,63 +292,75 @@ export function ClerkProvider(props: ClerkProviderProps {
isMountedRef.current = false;
};
- }, [pk, clerkInstance]);
+ }, [publishableKey, tokenCache, clerkInstance, isSyncingNativeClientToJsRef]);
- // Listen for native auth state changes and sync to JS SDK
- const { nativeAuthState } = useNativeAuthEvents();
+ 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 isSyncingNativeClientToJsRef = useRef(false);
+ const isMountedRef = useNativeSessionBootstrap({
+ isSyncingNativeClientToJsRef,
+ publishableKey: pk,
+ tokenCache,
+ clerkInstance,
+ });
+
+ // Listen for native client events and reload JS from the client source of truth.
+ const { nativeClientEvent } = useNativeClientEvents();
useEffect(() => {
- if (!nativeAuthState || !clerkInstance) {
+ if (!nativeClientEvent || !clerkInstance) {
return;
}
- const syncNativeAuthToJs = async () => {
+ const syncNativeClientStateToJs = async () => {
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) {
- if (!isMountedRef.current) {
- return;
- }
- await clerkInstance.signOut();
+ if (!isMountedRef.current) {
+ return;
+ }
+ isSyncingNativeClientToJsRef.current = true;
+ try {
+ await syncNativeClientToJs({
+ clerkInstance,
+ tokenCache,
+ });
+ } finally {
+ isSyncingNativeClientToJsRef.current = false;
}
} catch (error) {
if (__DEV__) {
- console.error(`[ClerkProvider] Failed to sync native auth state:`, error);
+ console.error(`[ClerkProvider] Failed to sync native client state:`, error);
}
}
};
- void syncNativeAuthToJs();
- }, [nativeAuthState, clerkInstance]);
+ void syncNativeClientStateToJs();
+ }, [nativeClientEvent, clerkInstance, tokenCache, isMountedRef]);
// Needed for `useOAuth` / `useSSO` to work correctly on web — must stay synchronous during render
// so the redirect URL is caught before children mount. Resolves to a no-op on native via the
@@ -400,7 +390,9 @@ export function ClerkProvider(props: ClerkProviderProps
{isNative() && (
-
diff --git a/packages/expo/src/specs/NativeClerkAuthView.android.ts b/packages/expo/src/specs/NativeClerkAuthView.android.ts
new file mode 100644
index 00000000000..c41bf58a5ea
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkAuthView.android.ts
@@ -0,0 +1,13 @@
+import { requireNativeView } from 'expo';
+import type { ViewProps } from 'react-native';
+
+type AuthEvent = Readonly<{ type: string }>;
+type NativeEvent = Readonly<{ nativeEvent: T }>;
+
+interface NativeProps extends ViewProps {
+ mode?: string;
+ isDismissible?: boolean;
+ onAuthEvent?: (event: NativeEvent) => void;
+}
+
+export default requireNativeView('ClerkAuthView');
diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo/src/specs/NativeClerkAuthView.ts
index e4cffd1497d..321734c692b 100644
--- a/packages/expo/src/specs/NativeClerkAuthView.ts
+++ b/packages/expo/src/specs/NativeClerkAuthView.ts
@@ -5,11 +5,11 @@ 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 AuthEvent = Readonly<{ type: string; data: string }>;
+type AuthEvent = Readonly<{ type: string }>;
interface NativeProps extends ViewProps {
mode?: string;
- isDismissable?: boolean;
+ isDismissible?: boolean;
onAuthEvent?: BubblingEventHandler;
}
diff --git a/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts b/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts
new file mode 100644
index 00000000000..4ad961835a5
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts
@@ -0,0 +1,13 @@
+import { requireNativeModule } from 'expo';
+
+type NativeMap = Record;
+
+interface Spec {
+ configure(params: NativeMap): void;
+ signIn(params: NativeMap | null): Promise;
+ createAccount(params: NativeMap | null): Promise;
+ presentExplicitSignIn(params: NativeMap | null): Promise;
+ signOut(): Promise;
+}
+
+export default requireNativeModule('ClerkGoogleSignIn');
diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts
new file mode 100644
index 00000000000..1ffb278809a
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkModule.android.ts
@@ -0,0 +1,16 @@
+import { requireNativeModule } from 'expo';
+
+type NativeMap = Record;
+
+interface Spec {
+ // addListener/removeListeners are present on the iOS RN event emitter module.
+ // Android uses Expo Modules EventEmitter instead.
+ addListener?(eventName: string): void;
+ configure(publishableKey: string, bearerToken: string | null): Promise;
+ getSession(): Promise;
+ getClientToken(): Promise;
+ refreshClient(): Promise;
+ removeListeners?(count: number): void;
+}
+
+export default requireNativeModule('ClerkExpo');
diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts
index 1c38d2c1f92..9600dea3835 100644
--- a/packages/expo/src/specs/NativeClerkModule.ts
+++ b/packages/expo/src/specs/NativeClerkModule.ts
@@ -3,12 +3,16 @@ 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 client refresh 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;
- signOut(): Promise;
+ refreshClient(): Promise;
+ // Required by NativeEventEmitter for internal native client refresh events.
+ // This is not part of the public @clerk/expo API.
+ removeListeners(count: number): void;
}
export default TurboModuleRegistry.get('ClerkExpo');
diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.android.ts b/packages/expo/src/specs/NativeClerkUserButtonView.android.ts
new file mode 100644
index 00000000000..90db3f4a4dc
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkUserButtonView.android.ts
@@ -0,0 +1,9 @@
+import { requireNativeView } from 'expo';
+import type { ViewProps } from 'react-native';
+
+// Codegen requires an interface declaration in the iOS spec; keep the Android
+// view prop shape identical for the shared React wrapper.
+// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+interface NativeProps extends ViewProps {}
+
+export default requireNativeView('ClerkUserButtonView');
diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.ts b/packages/expo/src/specs/NativeClerkUserButtonView.ts
new file mode 100644
index 00000000000..e5541213487
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkUserButtonView.ts
@@ -0,0 +1,11 @@
+/* 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 */
+
+// Codegen requires an interface declaration here; a type alias fails Android codegen.
+// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+interface NativeProps extends ViewProps {}
+
+export default codegenNativeComponent('ClerkUserButtonView') as HostComponent;
diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.android.ts b/packages/expo/src/specs/NativeClerkUserProfileView.android.ts
new file mode 100644
index 00000000000..7adcbd11dcf
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkUserProfileView.android.ts
@@ -0,0 +1,12 @@
+import { requireNativeView } from 'expo';
+import type { ViewProps } from 'react-native';
+
+type ProfileEvent = Readonly<{ type: string }>;
+type NativeEvent = Readonly<{ nativeEvent: T }>;
+
+interface NativeProps extends ViewProps {
+ isDismissible?: boolean;
+ onProfileEvent?: (event: NativeEvent) => void;
+}
+
+export default requireNativeView('ClerkUserProfileView');
diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo/src/specs/NativeClerkUserProfileView.ts
index a6096769738..289dabf4d70 100644
--- a/packages/expo/src/specs/NativeClerkUserProfileView.ts
+++ b/packages/expo/src/specs/NativeClerkUserProfileView.ts
@@ -5,10 +5,10 @@ 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;
+ isDismissible?: boolean;
onProfileEvent?: BubblingEventHandler;
}
diff --git a/packages/expo/src/token-cache/index.ts b/packages/expo/src/token-cache/index.ts
index 0a577cbe577..ba9e46baea7 100644
--- a/packages/expo/src/token-cache/index.ts
+++ b/packages/expo/src/token-cache/index.ts
@@ -30,6 +30,9 @@ const createTokenCache = (): TokenCache => {
saveToken: (key: string, token: string) => {
return SecureStore.setItemAsync(key, token, secureStoreOpts);
},
+ clearToken: (key: string) => {
+ return SecureStore.deleteItemAsync(key, secureStoreOpts);
+ },
};
};
@@ -43,6 +46,7 @@ const createTokenCache = (): TokenCache => {
* To implement your own token cache, create an object that implements the `TokenCache` interface:
* - `getToken(key: string): Promise`
* - `saveToken(key: string, token: string): Promise`
+ * - `clearToken(key: string): void | Promise` (optional)
*
* @type {TokenCache | undefined} Object with `getToken` and `saveToken` methods, undefined on web
*/