diff --git a/.changeset/fix-native-bridge-quality.md b/.changeset/fix-native-bridge-quality.md
new file mode 100644
index 00000000000..0311fc57fdc
--- /dev/null
+++ b/.changeset/fix-native-bridge-quality.md
@@ -0,0 +1,17 @@
+---
+'@clerk/expo': patch
+'@clerk/react': patch
+---
+
+Fix code quality issues across native bridge layer
+
+- Fix forEach callback in `isomorphicClerk` to use block statement, avoiding implicit return lint warning
+- Emit `'error'` status instead of `'ready'` when `clerk.load()` fails in `isomorphicClerk`
+- Add `isMountedRef` guards after every `await` in `syncNativeAuthToJs` to prevent state mutations after unmount
+- Only call `onSuccess` after `syncNativeSession` succeeds in `AuthView`; call `onError` on sync failure
+- Replace non-null assertion and `as any` cast with null-check and type guard in `syncNativeSession`
+- Cancel Recomposer and CoroutineScope in `onDetachedFromWindow` to prevent coroutine leaks (Android)
+- Replace direct SharedPreferences access with `Clerk.session?.fetchToken()` public API (Android)
+- Return `UIViewController` from inline view factories to preserve SwiftUI lifecycle (iOS)
+- Retain `UIHostingController` as child view controller in `ExpoView` subclasses (iOS)
+- Handle auth event stream termination and nil `createdSessionId` by invoking completion with failure (iOS)
diff --git a/.changeset/modern-hornets-fold.md b/.changeset/modern-hornets-fold.md
new file mode 100644
index 00000000000..a0fb384685f
--- /dev/null
+++ b/.changeset/modern-hornets-fold.md
@@ -0,0 +1,5 @@
+---
+"@clerk/react": patch
+---
+
+Fix `ReferenceError: Property 'document' doesn't exist` crash in React Native environments by conditionally loading UI scripts only in standard browser contexts.
diff --git a/packages/expo/NATIVE_IOS_SETUP.md b/packages/expo/NATIVE_IOS_SETUP.md
new file mode 100644
index 00000000000..2fb3cb9b249
--- /dev/null
+++ b/packages/expo/NATIVE_IOS_SETUP.md
@@ -0,0 +1,279 @@
+# Native iOS Setup for @clerk/clerk-expo
+
+This guide explains how to use Clerk's native iOS components in your Expo or React Native application.
+
+## Overview
+
+`@clerk/clerk-expo` supports two implementations:
+
+1. **Native-First (Recommended)**: Uses Clerk's native iOS Swift UI components for the best user experience
+2. **React Native**: Cross-platform React Native components that work everywhere
+
+## Feature Comparison
+
+| Feature | Native iOS (Swift UI) | React Native |
+| -------------------- | ------------------------------------ | ------------------------------- |
+| **UI/UX** | Native iOS design, follows Apple HIG | Cross-platform design |
+| **Performance** | Native Swift performance | JavaScript bridge overhead |
+| **Bundle Size** | Smaller JS bundle | Larger JS bundle |
+| **Customization** | Limited to Clerk iOS theming | Full React Native customization |
+| **Platform Support** | iOS only | iOS, Android, Web |
+| **Build Method** | Requires native build (EAS/Xcode) | Works with Expo Go |
+| **Face ID/Touch ID** | Native biometric integration | Via expo-local-authentication |
+| **Passkeys** | Native passkey support | Limited support |
+| **OAuth** | Native SFAuthenticationSession | WebBrowser-based |
+
+---
+
+## Setup Instructions
+
+### For Expo Users (Recommended)
+
+#### Prerequisites
+
+- Expo SDK 50 or later
+- EAS Build account (native builds required)
+- iOS deployment target 15.1+
+
+#### 1. Install the Package
+
+```bash
+npx expo install @clerk/clerk-expo
+```
+
+#### 2. Add the Expo Config Plugin
+
+In your `app.json` or `app.config.js`:
+
+```json
+{
+ "expo": {
+ "plugins": [["@clerk/clerk-expo/app.plugin"]]
+ }
+}
+```
+
+#### 3. Configure Your App
+
+```tsx
+// app/_layout.tsx
+import { ClerkProvider } from '@clerk/clerk-expo';
+
+export default function RootLayout() {
+ return (
+
+ {/* Your app content */}
+
+ );
+}
+```
+
+#### 4. Use Native Components
+
+```tsx
+// app/(auth)/sign-in.tsx
+import { SignIn } from '@clerk/clerk-expo/native';
+import { useRouter } from 'expo-router';
+
+export default function SignInScreen() {
+ const router = useRouter();
+
+ return (
+ router.replace('/(home)')}
+ onError={error => console.error('Sign in error:', error)}
+ />
+ );
+}
+```
+
+#### 5. Build with EAS
+
+The native iOS components require a native build:
+
+```bash
+# Development build
+eas build --profile development --platform ios
+
+# Install on simulator
+eas build:run --profile development --platform ios
+
+# Production build
+eas build --profile production --platform ios
+```
+
+**Important**: Native iOS components **will not work** with Expo Go. You must create a development build.
+
+---
+
+### For React Native CLI Users
+
+If you're using React Native without Expo, you'll need to manually add the clerk-ios Swift package.
+
+#### Prerequisites
+
+- React Native 0.70 or later
+- CocoaPods
+- Xcode 14+
+- iOS deployment target 15.1+
+
+#### 1. Install the Package
+
+```bash
+npm install @clerk/clerk-expo
+# or
+yarn add @clerk/clerk-expo
+```
+
+#### 2. Install iOS Dependencies
+
+```bash
+cd ios && pod install && cd ..
+```
+
+#### 3. Add clerk-ios Swift Package in Xcode
+
+1. Open your `.xcworkspace` file in Xcode
+2. Select your project in the Project Navigator
+3. Select your app target
+4. Go to the "Package Dependencies" tab
+5. Click the "+" button
+6. Enter the repository URL: `https://github.com/clerk/clerk-ios.git`
+7. Select "Up to Next Major Version" with minimum version `0.68.1`
+8. Ensure the "Clerk" product is selected for your target
+9. Click "Add Package"
+
+#### 4. Verify Installation
+
+Build your project to ensure the Swift package is properly linked:
+
+```bash
+npx react-native run-ios
+```
+
+---
+
+## Using React Native Components Instead
+
+If you want to use the cross-platform React Native components (works with Expo Go), import from the main package:
+
+```tsx
+import { SignIn } from '@clerk/clerk-expo';
+// NOT from '@clerk/clerk-expo/native'
+```
+
+### When to Use React Native Components
+
+- Testing in Expo Go
+- Need Android support
+- Want full UI customization
+- Don't need native iOS features (Face ID, Passkeys)
+
+### When to Use Native iOS Components
+
+- Building a production iOS app
+- Want the best iOS user experience
+- Need native biometric authentication
+- Want smaller JavaScript bundle size
+- Need passkey support
+
+---
+
+## API Reference
+
+### Native SignIn Component
+
+```tsx
+import { SignIn } from '@clerk/clerk-expo/native';
+
+ void}
+ onError={(error) => void}
+/>
+```
+
+**Props:**
+
+- `mode`: Authentication mode (default: `"signInOrUp"`)
+- `isDismissable`: Whether the view can be dismissed (default: `true`)
+- `onSuccess`: Callback when authentication succeeds
+- `onError`: Callback when authentication fails
+
+---
+
+## Troubleshooting
+
+### "Module 'Clerk' not found"
+
+The clerk-ios Swift package isn't installed. Follow the manual setup steps above.
+
+### "Expo Go doesn't show native components"
+
+Native components require a development build. Run `eas build --profile development --platform ios`.
+
+### Plugin doesn't add Swift package
+
+The config plugin only runs during `expo prebuild` or `eas build`. If you're using a bare workflow, you'll need to add the package manually in Xcode.
+
+### Build fails with Swift errors
+
+Ensure your iOS deployment target is at least 15.1 in your `Podfile`:
+
+```ruby
+platform :ios, '15.1'
+```
+
+---
+
+## Migration Guide
+
+### From React Native Components to Native
+
+1. Change your imports:
+
+```tsx
+// Before
+import { SignIn } from '@clerk/clerk-expo';
+
+// After
+import { SignIn } from '@clerk/clerk-expo/native';
+```
+
+2. Create a development build (can't use Expo Go)
+3. Test on a physical device or simulator
+
+### From Native to React Native
+
+1. Change your imports back:
+
+```tsx
+// Before
+import { SignIn } from '@clerk/clerk-expo/native';
+
+// After
+import { SignIn } from '@clerk/clerk-expo';
+```
+
+2. Can now use Expo Go for testing
+
+---
+
+## Additional Resources
+
+- [Clerk iOS SDK Documentation](https://github.com/clerk/clerk-ios)
+- [Expo Config Plugins](https://docs.expo.dev/config-plugins/introduction/)
+- [EAS Build Documentation](https://docs.expo.dev/build/introduction/)
+- [Clerk Dashboard](https://dashboard.clerk.com/)
+
+---
+
+## Support
+
+For issues related to:
+
+- Native iOS components: [clerk-ios repository](https://github.com/clerk/clerk-ios/issues)
+- Expo integration: [clerk-javascript repository](https://github.com/clerk/javascript/issues)
+- General Clerk questions: [Clerk Discord](https://clerk.com/discord)
diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle
index ee1fab8fa00..44bf055e5f6 100644
--- a/packages/expo/android/build.gradle
+++ b/packages/expo/android/build.gradle
@@ -1,5 +1,8 @@
-apply plugin: 'com.android.library'
-apply plugin: 'kotlin-android'
+plugins {
+ id 'com.android.library'
+ id 'org.jetbrains.kotlin.android'
+ id 'org.jetbrains.kotlin.plugin.compose' version '2.1.20'
+}
group = 'com.clerk.expo'
version = '1.0.0'
@@ -10,6 +13,11 @@ ext {
credentialsVersion = "1.3.0"
googleIdVersion = "1.1.1"
kotlinxCoroutinesVersion = "1.7.3"
+ clerkAndroidApiVersion = "0.1.30"
+ clerkAndroidUiVersion = "0.1.31"
+ composeVersion = "1.7.0"
+ activityComposeVersion = "1.9.0"
+ lifecycleVersion = "2.8.0"
}
def safeExtGet(prop, fallback) {
@@ -17,7 +25,7 @@ def safeExtGet(prop, fallback) {
}
android {
- namespace "expo.modules.clerk.googlesignin"
+ namespace "expo.modules.clerk"
compileSdk safeExtGet("compileSdkVersion", 36)
@@ -43,6 +51,16 @@ android {
jvmTarget = "17"
}
+ buildFeatures {
+ compose = true
+ }
+
+ packaging {
+ resources {
+ excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
+ }
+ }
+
sourceSets {
main {
java.srcDirs = ['src/main/java']
@@ -50,6 +68,8 @@ android {
}
}
+// Note: kotlin-stdlib exclusions are handled in the clerk-android-ui dependency declaration
+
dependencies {
// Expo modules core
implementation project(':expo-modules-core')
@@ -61,4 +81,22 @@ dependencies {
// Coroutines for async operations
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion"
+
+ // Clerk Android SDK with prebuilt UI
+ // Exclude kotlin-stdlib to prevent 2.3.0 from polluting the project
+ // Exclude okhttp to prevent version conflict with React Native's okhttp
+ implementation("com.clerk:clerk-android-ui:$clerkAndroidUiVersion") {
+ exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib'
+ exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk7'
+ exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8'
+ exclude group: 'com.squareup.okhttp3', module: 'okhttp'
+ exclude group: 'com.squareup.okhttp3', module: 'okhttp-urlconnection'
+ }
+
+ // Jetpack Compose for wrapping Clerk views
+ implementation "androidx.compose.ui:ui:$composeVersion"
+ implementation "androidx.compose.material3:material3:1.3.0"
+ implementation "androidx.activity:activity-compose:$activityComposeVersion"
+ implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion"
+ implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion"
}
diff --git a/packages/expo/android/src/main/AndroidManifest.xml b/packages/expo/android/src/main/AndroidManifest.xml
index a2f47b6057d..4683222f409 100644
--- a/packages/expo/android/src/main/AndroidManifest.xml
+++ b/packages/expo/android/src/main/AndroidManifest.xml
@@ -1,2 +1,17 @@
+
+
+
+
+
+
+
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
new file mode 100644
index 00000000000..7ef0bee81a0
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt
@@ -0,0 +1,303 @@
+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 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 - initialSession: ${initialSession?.id}, 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 < 30) { // Wait up to 3 seconds
+ val client = Clerk.client
+ if (client != null) {
+ debugLog(TAG, "Client is ready: ${client.id}")
+ isClientReady = true
+ break
+ }
+ delay(100)
+ 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(500) // Check every 500ms
+ val client = Clerk.client
+ val signUp = client?.signUp
+
+ if (signUp != null && signUp.id != lastSignUpId) {
+ lastSignUpId = signUp.id
+ debugLog(TAG, "New signUp detected: ${signUp.id}, 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 = null // Use default theme, or pass custom
+ )
+ }
+ 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
new file mode 100644
index 00000000000..77b17158bdd
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt
@@ -0,0 +1,138 @@
+package expo.modules.clerk
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.util.Log
+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.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 expo.modules.kotlin.AppContext
+import expo.modules.kotlin.viewevent.EventDispatcher
+import expo.modules.kotlin.views.ExpoView
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+private const val TAG = "ClerkAuthExpoView"
+
+class ClerkAuthExpoView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
+ private val onAuthEvent by EventDispatcher()
+
+ var mode: String = "signInOrUp"
+ var isDismissable: Boolean = true
+
+ private 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)
+
+ // 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.
+ // AndroidUiDispatcher.Main provides both a dispatcher and MonotonicFrameClock.
+ 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()
+ }
+
+ // Track the initial session to detect new sign-ins
+ private var initialSessionId: String? = Clerk.session?.id
+
+ fun setupView() {
+ Log.d(TAG, "setupView - mode: $mode, isDismissable: $isDismissable, activity: $activity")
+
+ composeView.setContent {
+ val session by Clerk.sessionFlow.collectAsStateWithLifecycle()
+
+ // Detect auth completion: session appeared when there wasn't one
+ LaunchedEffect(session) {
+ val currentSession = session
+ if (currentSession != null && initialSessionId == null) {
+ Log.d(TAG, "Auth completed - session: ${currentSession.id}")
+ onAuthEvent(mapOf(
+ "type" to "signInCompleted",
+ "data" to 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 = null
+ )
+ }
+ }
+ }
+
+ if (activity != null) {
+ CompositionLocalProvider(
+ LocalViewModelStoreOwner provides activity,
+ LocalLifecycleOwner provides activity,
+ LocalSavedStateRegistryOwner provides activity,
+ ) {
+ content()
+ }
+ } else {
+ Log.e(TAG, "No ComponentActivity found!")
+ 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
new file mode 100644
index 00000000000..b41176929cb
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
@@ -0,0 +1,388 @@
+package expo.modules.clerk
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import com.clerk.api.Clerk
+import expo.modules.kotlin.Promise
+import expo.modules.kotlin.exception.CodedException
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import expo.modules.kotlin.records.Field
+import expo.modules.kotlin.records.Record
+import expo.modules.kotlin.views.ExpoView
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
+
+private const val TAG = "ClerkExpoModule"
+
+private fun debugLog(tag: String, message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d(tag, message)
+ }
+}
+
+// Parameter records
+class PresentAuthOptions : Record {
+ @Field
+ var mode: String = "signInOrUp"
+
+ @Field
+ var dismissable: Boolean = true
+}
+
+class PresentProfileOptions : Record {
+ @Field
+ var dismissable: Boolean = true
+}
+
+// Custom exceptions
+class ClerkNotInitializedException : CodedException(
+ "Clerk SDK is not initialized. Call configure() first."
+)
+
+class ClerkActivityUnavailableException : CodedException(
+ "No activity available to present Clerk UI."
+)
+
+class ClerkAlreadySignedInException : CodedException(
+ "User is already signed in"
+)
+
+class ClerkInitializationException(cause: Throwable?) : CodedException(
+ "Failed to initialize Clerk SDK: ${cause?.message}",
+ cause
+)
+
+class ClerkExpoModule : Module() {
+ companion object {
+ const val CLERK_AUTH_REQUEST_CODE = 9001
+ const val CLERK_PROFILE_REQUEST_CODE = 9002
+
+ // Intent extras
+ const val EXTRA_DISMISSABLE = "dismissable"
+ const val EXTRA_PUBLISHABLE_KEY = "publishableKey"
+ const val EXTRA_MODE = "mode"
+
+ // Result extras
+ const val RESULT_SESSION_ID = "sessionId"
+ const val RESULT_CANCELLED = "cancelled"
+
+ // Pending promises for activity results
+ private var pendingAuthPromise: Promise? = null
+ private var pendingProfilePromise: Promise? = null
+
+ // Store publishable key for passing to activities
+ private var publishableKey: String? = null
+ }
+
+ private val context: Context
+ get() = requireNotNull(appContext.reactContext) { "React context is null" }
+
+ private val currentActivity: Activity?
+ get() = appContext.currentActivity
+
+ private val coroutineScope = CoroutineScope(Dispatchers.Main)
+
+ override fun definition() = ModuleDefinition {
+ Name("ClerkExpo")
+
+ // Initialize Clerk SDK with publishable key
+ AsyncFunction("configure") { pubKey: String, promise: Promise ->
+ coroutineScope.launch {
+ try {
+ publishableKey = pubKey
+ Clerk.initialize(context, pubKey)
+
+ // Wait for initialization to complete with timeout
+ try {
+ withTimeout(10_000L) {
+ Clerk.isInitialized.first { it }
+ }
+ } catch (e: TimeoutCancellationException) {
+ val initError = Clerk.initializationError.value
+ val message = if (initError != null) {
+ "Clerk initialization timed out: ${initError.message}"
+ } else {
+ "Clerk initialization timed out after 10 seconds"
+ }
+ promise.reject(CodedException(message))
+ return@launch
+ }
+
+ // Check for initialization errors
+ val error = Clerk.initializationError.value
+ if (error != null) {
+ promise.reject(ClerkInitializationException(error))
+ } else {
+ promise.resolve(null)
+ }
+ } catch (e: Exception) {
+ promise.reject(ClerkInitializationException(e))
+ }
+ }
+ }
+
+ // Present auth modal (sign-in, sign-up, or combined)
+ AsyncFunction("presentAuth") { options: PresentAuthOptions, promise: Promise ->
+ val activity = currentActivity ?: run {
+ promise.reject(ClerkActivityUnavailableException())
+ return@AsyncFunction
+ }
+
+ if (!Clerk.isInitialized.value) {
+ promise.reject(ClerkNotInitializedException())
+ return@AsyncFunction
+ }
+
+ // Check if user is already signed in
+ if (Clerk.session != null) {
+ promise.reject(ClerkAlreadySignedInException())
+ return@AsyncFunction
+ }
+
+ pendingAuthPromise?.reject(CodedException("Auth presentation was superseded"))
+ pendingAuthPromise = promise
+
+ val intent = Intent(activity, ClerkAuthActivity::class.java).apply {
+ putExtra(EXTRA_MODE, options.mode)
+ putExtra(EXTRA_DISMISSABLE, options.dismissable)
+ }
+
+ activity.startActivityForResult(intent, CLERK_AUTH_REQUEST_CODE)
+ }
+
+ // Present user profile modal
+ AsyncFunction("presentUserProfile") { options: PresentProfileOptions, promise: Promise ->
+ val activity = currentActivity ?: run {
+ promise.reject(ClerkActivityUnavailableException())
+ return@AsyncFunction
+ }
+
+ if (!Clerk.isInitialized.value) {
+ promise.reject(ClerkNotInitializedException())
+ return@AsyncFunction
+ }
+
+ pendingProfilePromise?.reject(CodedException("Profile presentation was superseded"))
+ pendingProfilePromise = promise
+
+ val intent = Intent(activity, ClerkUserProfileActivity::class.java).apply {
+ putExtra(EXTRA_DISMISSABLE, options.dismissable)
+ putExtra(EXTRA_PUBLISHABLE_KEY, publishableKey)
+ }
+
+ activity.startActivityForResult(intent, CLERK_PROFILE_REQUEST_CODE)
+ }
+
+ // Get current session and user data
+ AsyncFunction("getSession") { promise: Promise ->
+ if (!Clerk.isInitialized.value) {
+ promise.reject(ClerkNotInitializedException())
+ return@AsyncFunction
+ }
+
+ val session = Clerk.session
+ val user = Clerk.user
+
+ debugLog(TAG, "getSession - session: ${session?.id}, user: ${user?.id}")
+
+ val result = mutableMapOf()
+
+ session?.let {
+ result["session"] = mapOf(
+ "id" to it.id,
+ "status" to it.status.name,
+ "userId" to it.user?.id,
+ "createdAt" to it.createdAt,
+ "updatedAt" to it.updatedAt,
+ "expireAt" to it.expireAt,
+ "lastActiveAt" to it.lastActiveAt
+ )
+ }
+
+ user?.let {
+ val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId }
+ val primaryPhone = it.phoneNumbers.find { p -> p.id == it.primaryPhoneNumberId }
+
+ 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,
+ "passwordEnabled" to it.passwordEnabled,
+ "totpEnabled" to it.totpEnabled,
+ "createdAt" to it.createdAt,
+ "lastSignInAt" to it.lastSignInAt
+ )
+ }
+
+ promise.resolve(result)
+ }
+
+ // Get the native Clerk client's bearer token
+ // This allows the JS SDK to use the same client as the native SDK
+ AsyncFunction("getClientToken") { promise: Promise ->
+ coroutineScope.launch {
+ try {
+ val session = Clerk.session
+ if (session == null) {
+ promise.resolve(null)
+ return@launch
+ }
+ val token = session.fetchToken()
+ promise.resolve(token?.jwt)
+ } catch (e: Exception) {
+ debugLog(TAG, "getClientToken failed: ${e.message}")
+ promise.resolve(null)
+ }
+ }
+ }
+
+ // Sign out the current user
+ AsyncFunction("signOut") { promise: Promise ->
+ if (!Clerk.isInitialized.value) {
+ promise.reject(ClerkNotInitializedException())
+ return@AsyncFunction
+ }
+
+ coroutineScope.launch {
+ try {
+ Clerk.signOut()
+ promise.resolve(null)
+ } catch (e: Exception) {
+ promise.reject(
+ CodedException(e.message ?: "Sign out failed")
+ )
+ }
+ }
+ }
+
+ // Handle activity results
+ OnActivityResult { _, payload ->
+ val (requestCode, resultCode, data) = payload
+
+ when (requestCode) {
+ CLERK_AUTH_REQUEST_CODE -> {
+ handleAuthResult(resultCode, data)
+ }
+ CLERK_PROFILE_REQUEST_CODE -> {
+ handleProfileResult(resultCode, data)
+ }
+ }
+ }
+
+ // MARK: - Inline Native Views
+
+ View(ClerkAuthExpoView::class) {
+ Events("onAuthEvent")
+
+ Prop("mode") { view: ClerkAuthExpoView, mode: String? ->
+ view.mode = mode ?: "signInOrUp"
+ }
+
+ Prop("isDismissable") { view: ClerkAuthExpoView, dismissable: Boolean? ->
+ view.isDismissable = dismissable ?: true
+ }
+
+ OnViewDidUpdateProps { view ->
+ view.setupView()
+ }
+ }
+
+ View(ClerkUserProfileExpoView::class) {
+ Events("onProfileEvent")
+
+ Prop("isDismissable") { view: ClerkUserProfileExpoView, dismissable: Boolean? ->
+ view.isDismissable = dismissable ?: true
+ }
+
+ OnViewDidUpdateProps { view ->
+ view.setupView()
+ }
+ }
+ }
+
+ private fun handleAuthResult(resultCode: Int, data: Intent?) {
+ debugLog(TAG, "handleAuthResult - resultCode: $resultCode")
+
+ val promise = pendingAuthPromise ?: return
+ pendingAuthPromise = null
+
+ if (resultCode == Activity.RESULT_OK) {
+ val session = Clerk.session
+ val user = Clerk.user
+
+ debugLog(TAG, "handleAuthResult - session: ${session?.id}, user: ${user?.id}")
+
+ val result = mutableMapOf()
+
+ session?.let {
+ 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 }
+
+ result["user"] = mapOf(
+ "id" to it.id,
+ "firstName" to it.firstName,
+ "lastName" to it.lastName,
+ "imageUrl" to it.imageUrl,
+ "primaryEmailAddress" to primaryEmail?.emailAddress
+ )
+ }
+
+ promise.resolve(result)
+ } else {
+ debugLog(TAG, "handleAuthResult - user cancelled")
+ promise.resolve(mapOf("cancelled" to true))
+ }
+ }
+
+ 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 = mutableMapOf()
+
+ session?.let {
+ 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 }
+
+ result["user"] = mapOf(
+ "id" to it.id,
+ "firstName" to it.firstName,
+ "lastName" to it.lastName,
+ "imageUrl" to it.imageUrl,
+ "primaryEmailAddress" to primaryEmail?.emailAddress
+ )
+ }
+
+ result["dismissed"] = resultCode == Activity.RESULT_CANCELED
+
+ promise.resolve(result)
+ }
+}
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
new file mode 100644
index 00000000000..56d9fc8513a
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt
@@ -0,0 +1,126 @@
+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.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.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 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)
+
+ // Log current state
+ Log.d(TAG, "onCreate - isInitialized: ${Clerk.isInitialized.value}")
+ Log.d(TAG, "onCreate - session: ${Clerk.session?.id}")
+ Log.d(TAG, "onCreate - user: ${Clerk.user?.id}")
+ Log.d(TAG, "onCreate - user.firstName: ${Clerk.user?.firstName}")
+ Log.d(TAG, "onCreate - user.lastName: ${Clerk.user?.lastName}")
+ Log.d(TAG, "onCreate - user.imageUrl: ${Clerk.user?.imageUrl?.take(50)}")
+ Log.d(TAG, "onCreate - user.emailAddresses: ${Clerk.user?.emailAddresses?.map { it.emailAddress }}")
+
+ // Initialize Clerk if not already initialized
+ if (publishableKey != null && !Clerk.isInitialized.value) {
+ Log.d(TAG, "Initializing Clerk with publishable key: ${publishableKey.take(20)}...")
+ 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) {
+ Log.d(TAG, "State changed - session: ${session?.id}, user: ${user?.id}")
+ Log.d(TAG, "State changed - user.firstName: ${user?.firstName}, user.lastName: ${user?.lastName}")
+ Log.d(TAG, "State changed - user.imageUrl: ${user?.imageUrl?.take(50)}")
+ }
+
+ // Detect sign-out: if we had a session and now it's null, user signed out
+ LaunchedEffect(session) {
+ if (hadSession && session == null) {
+ Log.d(TAG, "Sign-out detected - session became null, dismissing activity")
+ 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
+ if (!dismissable) {
+ onBackPressedDispatcher.addCallback(this, object : androidx.activity.OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ // Do nothing - not dismissable
+ }
+ })
+ }
+ }
+
+ override fun onBackPressed() {
+ if (intent.getBooleanExtra(ClerkExpoModule.EXTRA_DISMISSABLE, true)) {
+ 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
new file mode 100644
index 00000000000..6277dbd20fa
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt
@@ -0,0 +1,124 @@
+package expo.modules.clerk
+
+import android.content.Context
+import android.util.Log
+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.platform.AndroidUiDispatcher
+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.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.ui.userprofile.UserProfileView
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.viewevent.EventDispatcher
+import expo.modules.kotlin.views.ExpoView
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+private const val TAG = "ClerkUserProfileExpoView"
+
+class ClerkUserProfileExpoView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
+ private val onProfileEvent by EventDispatcher()
+
+ var isDismissable: Boolean = true
+
+ private val activity = ClerkAuthExpoView.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")
+ onProfileEvent(mapOf(
+ "type" to "signedOut",
+ "data" to 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")
+ onProfileEvent(mapOf(
+ "type" to "dismissed",
+ "data" to emptyMap()
+ ))
+ }
+ )
+ }
+ }
+ }
+
+ if (activity != null) {
+ CompositionLocalProvider(
+ LocalViewModelStoreOwner provides activity,
+ LocalLifecycleOwner provides activity,
+ LocalSavedStateRegistryOwner provides activity,
+ ) {
+ content()
+ }
+ } else {
+ content()
+ }
+ }
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt
new file mode 100644
index 00000000000..e96e9078f44
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt
@@ -0,0 +1,102 @@
+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.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
new file mode 100644
index 00000000000..7b82bd1ec20
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt
@@ -0,0 +1,52 @@
+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/app.plugin.d.ts b/packages/expo/app.plugin.d.ts
new file mode 100644
index 00000000000..82abd6c984b
--- /dev/null
+++ b/packages/expo/app.plugin.d.ts
@@ -0,0 +1,6 @@
+export = withClerkExpo;
+/**
+ * Combined Clerk Expo plugin
+ */
+declare function withClerkExpo(config: any): any;
+//# sourceMappingURL=app.plugin.d.ts.map
diff --git a/packages/expo/app.plugin.d.ts.map b/packages/expo/app.plugin.d.ts.map
new file mode 100644
index 00000000000..99752a11459
--- /dev/null
+++ b/packages/expo/app.plugin.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"app.plugin.d.ts","sourceRoot":"","sources":["app.plugin.js"],"names":[],"mappings":";AA0gBA;;GAEG;AACH,iDAKC"}
\ No newline at end of file
diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js
index 65835131de7..23790c9489f 100644
--- a/packages/expo/app.plugin.js
+++ b/packages/expo/app.plugin.js
@@ -1 +1,585 @@
-module.exports = require('./dist/plugin/withClerkExpo');
+/**
+ * Expo config plugin for @clerk/clerk-expo
+ * Automatically configures iOS and Android to work with Clerk native components
+ *
+ * When this plugin is used:
+ * 1. Native modules are enabled by writing the full expo-module.config.json
+ * 2. iOS is configured with Swift Package Manager dependency for clerk-ios
+ * 3. Android is configured with packaging exclusions for dependencies
+ */
+const { withXcodeProject, withDangerousMod, withInfoPlist, withAppBuildGradle } = require('@expo/config-plugins');
+const path = require('path');
+const fs = require('fs');
+
+const CLERK_IOS_REPO = 'https://github.com/clerk/clerk-ios.git';
+const CLERK_IOS_VERSION = '0.68.1';
+
+const CLERK_MIN_IOS_VERSION = '17.0';
+
+/**
+ * The native module configuration that gets enabled when this plugin is used.
+ * By default, @clerk/expo ships with an empty config ({ "platforms": [] }) so that
+ * users who don't need native features can use the package without native build issues.
+ */
+const NATIVE_MODULE_CONFIG = {
+ platforms: ['android', 'ios'],
+ android: {
+ modules: ['expo.modules.clerk.ClerkExpoModule', 'expo.modules.clerk.googlesignin.ClerkGoogleSignInModule'],
+ },
+ ios: {
+ modules: ['ClerkExpoModule', 'ClerkGoogleSignInModule'],
+ },
+};
+
+/**
+ * Enable native modules by writing the full expo-module.config.json.
+ * This must run BEFORE autolinking scans node_modules for native modules.
+ */
+const withClerkNativeModules = config => {
+ // Use withDangerousMod to write the config file during prebuild
+ return withDangerousMod(config, [
+ 'ios',
+ async modConfig => {
+ try {
+ // Find the @clerk/expo package directory in node_modules
+ const packageJsonPath = require.resolve('@clerk/expo/package.json');
+ const packageDir = path.dirname(packageJsonPath);
+ const configPath = path.join(packageDir, 'expo-module.config.json');
+
+ // Write the config that enables native modules
+ fs.writeFileSync(configPath, JSON.stringify(NATIVE_MODULE_CONFIG, null, 2) + '\n');
+
+ console.log('✅ Clerk native modules enabled');
+ } catch (error) {
+ console.warn('⚠️ Could not enable Clerk native modules:', error.message);
+ }
+
+ return modConfig;
+ },
+ ]);
+};
+
+const withClerkIOS = config => {
+ console.log('✅ Clerk iOS plugin loaded');
+
+ // IMPORTANT: Set iOS deployment target in Podfile.properties.json BEFORE pod install
+ // This ensures ClerkExpo pod gets installed (it requires iOS 17.0)
+ config = withDangerousMod(config, [
+ 'ios',
+ async config => {
+ const podfilePropertiesPath = path.join(config.modRequest.platformProjectRoot, 'Podfile.properties.json');
+
+ let properties = {};
+ if (fs.existsSync(podfilePropertiesPath)) {
+ try {
+ properties = JSON.parse(fs.readFileSync(podfilePropertiesPath, 'utf8'));
+ } catch {
+ // If file exists but is invalid JSON, start fresh
+ }
+ }
+
+ // Set the iOS deployment target
+ if (
+ !properties['ios.deploymentTarget'] ||
+ parseFloat(properties['ios.deploymentTarget']) < parseFloat(CLERK_MIN_IOS_VERSION)
+ ) {
+ properties['ios.deploymentTarget'] = CLERK_MIN_IOS_VERSION;
+ fs.writeFileSync(podfilePropertiesPath, JSON.stringify(properties, null, 2) + '\n');
+ console.log(`✅ Set ios.deploymentTarget to ${CLERK_MIN_IOS_VERSION} in Podfile.properties.json`);
+ }
+
+ return config;
+ },
+ ]);
+
+ // First update the iOS deployment target to 17.0 (required by Clerk iOS SDK)
+ config = withXcodeProject(config, config => {
+ const xcodeProject = config.modResults;
+
+ try {
+ // Update deployment target in all build configurations
+ const buildConfigs = xcodeProject.hash.project.objects.XCBuildConfiguration || {};
+
+ for (const [uuid, buildConfig] of Object.entries(buildConfigs)) {
+ if (buildConfig && buildConfig.buildSettings) {
+ const currentTarget = buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET;
+ if (currentTarget && parseFloat(currentTarget) < parseFloat(CLERK_MIN_IOS_VERSION)) {
+ buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET = CLERK_MIN_IOS_VERSION;
+ }
+ }
+ }
+
+ console.log(`✅ Updated iOS deployment target to ${CLERK_MIN_IOS_VERSION}`);
+ } catch (error) {
+ console.error('❌ Error updating deployment target:', error.message);
+ }
+
+ return config;
+ });
+
+ // Then add the Swift Package dependency
+ config = withXcodeProject(config, config => {
+ const xcodeProject = config.modResults;
+
+ try {
+ // Get the main app target
+ const targets = xcodeProject.getFirstTarget();
+ if (!targets) {
+ console.warn('⚠️ Could not find main target in Xcode project');
+ return config;
+ }
+
+ const targetUuid = targets.uuid;
+ const targetName = targets.name;
+
+ // Add Swift Package reference to the project
+ const packageUuid = xcodeProject.generateUuid();
+ const packageName = 'clerk-ios';
+
+ // Add package reference to XCRemoteSwiftPackageReference section
+ if (!xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference) {
+ xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference = {};
+ }
+
+ xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference[packageUuid] = {
+ isa: 'XCRemoteSwiftPackageReference',
+ repositoryURL: CLERK_IOS_REPO,
+ requirement: {
+ kind: 'upToNextMajorVersion',
+ minimumVersion: CLERK_IOS_VERSION,
+ },
+ };
+
+ // Add package product dependency
+ const productUuid = xcodeProject.generateUuid();
+ if (!xcodeProject.hash.project.objects.XCSwiftPackageProductDependency) {
+ xcodeProject.hash.project.objects.XCSwiftPackageProductDependency = {};
+ }
+
+ xcodeProject.hash.project.objects.XCSwiftPackageProductDependency[productUuid] = {
+ isa: 'XCSwiftPackageProductDependency',
+ package: packageUuid,
+ productName: 'Clerk',
+ };
+
+ // Add package to project's package references
+ const projectSection = xcodeProject.hash.project.objects.PBXProject;
+ const projectUuid = Object.keys(projectSection)[0];
+ const project = projectSection[projectUuid];
+
+ if (!project.packageReferences) {
+ project.packageReferences = [];
+ }
+
+ // Check if package is already added
+ const alreadyAdded = project.packageReferences.some(ref => {
+ const refObj = xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference[ref.value];
+ return refObj && refObj.repositoryURL === CLERK_IOS_REPO;
+ });
+
+ if (!alreadyAdded) {
+ project.packageReferences.push({
+ value: packageUuid,
+ comment: packageName,
+ });
+ }
+
+ // Add package product to main app target
+ const nativeTarget = xcodeProject.hash.project.objects.PBXNativeTarget[targetUuid];
+ if (!nativeTarget.packageProductDependencies) {
+ nativeTarget.packageProductDependencies = [];
+ }
+
+ const productAlreadyAdded = nativeTarget.packageProductDependencies.some(dep => dep.value === productUuid);
+
+ if (!productAlreadyAdded) {
+ nativeTarget.packageProductDependencies.push({
+ value: productUuid,
+ comment: 'Clerk',
+ });
+ }
+
+ // Also add package to ClerkExpo pod target if it exists
+ const allTargets = xcodeProject.hash.project.objects.PBXNativeTarget;
+ for (const [uuid, target] of Object.entries(allTargets)) {
+ if (target && target.name === 'ClerkExpo') {
+ if (!target.packageProductDependencies) {
+ target.packageProductDependencies = [];
+ }
+
+ const podProductAlreadyAdded = target.packageProductDependencies.some(dep => dep.value === productUuid);
+
+ if (!podProductAlreadyAdded) {
+ target.packageProductDependencies.push({
+ value: productUuid,
+ comment: 'Clerk',
+ });
+ console.log(`✅ Added Clerk package to ClerkExpo pod target`);
+ }
+ }
+ }
+
+ console.log(`✅ Added clerk-ios Swift package dependency (${CLERK_IOS_VERSION})`);
+ } catch (error) {
+ console.error('❌ Error adding clerk-ios package:', error.message);
+ }
+
+ return config;
+ });
+
+ // Inject ClerkViewFactory.register() call into AppDelegate.swift
+ config = withDangerousMod(config, [
+ 'ios',
+ async config => {
+ const platformProjectRoot = config.modRequest.platformProjectRoot;
+ const projectName = config.modRequest.projectName;
+ const appDelegatePath = path.join(platformProjectRoot, projectName, 'AppDelegate.swift');
+
+ if (fs.existsSync(appDelegatePath)) {
+ let contents = fs.readFileSync(appDelegatePath, 'utf8');
+
+ // Check if already added
+ if (!contents.includes('ClerkViewFactory.register()')) {
+ // Find the didFinishLaunchingWithOptions method and add the registration call
+ // Look for the return statement in didFinishLaunching
+ const pattern = /(func application\s*\([^)]*didFinishLaunchingWithOptions[^)]*\)[^{]*\{)/;
+ const match = contents.match(pattern);
+
+ 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';
+ contents = contents.slice(0, insertPoint) + registrationCode + contents.slice(insertPoint);
+ fs.writeFileSync(appDelegatePath, contents);
+ console.log('✅ Added ClerkViewFactory.register() to AppDelegate.swift');
+ } else {
+ console.warn('⚠️ Could not find didFinishLaunchingWithOptions in AppDelegate.swift');
+ }
+ }
+ }
+
+ return config;
+ },
+ ]);
+
+ // Then inject ClerkViewFactory.swift into the app target
+ // This is required because the file uses `import Clerk` which is only available
+ // via SPM in the app target (CocoaPods targets can't see SPM packages)
+ config = withXcodeProject(config, config => {
+ try {
+ const platformProjectRoot = config.modRequest.platformProjectRoot;
+ const projectName = config.modRequest.projectName;
+ const iosProjectPath = path.join(platformProjectRoot, projectName);
+
+ // Find the ClerkViewFactory.swift source file
+ // Check multiple possible locations in order of preference
+ let sourceFile;
+ const possiblePaths = [
+ // Standard node_modules (npm, yarn)
+ path.join(config.modRequest.projectRoot, 'node_modules', '@clerk', 'expo', 'ios', 'ClerkViewFactory.swift'),
+ // pnpm hoisted node_modules
+ path.join(
+ config.modRequest.projectRoot,
+ '..',
+ 'node_modules',
+ '@clerk',
+ 'expo',
+ 'ios',
+ 'ClerkViewFactory.swift',
+ ),
+ // Monorepo workspace (pnpm workspace)
+ path.join(
+ config.modRequest.projectRoot,
+ '..',
+ 'javascript',
+ 'packages',
+ 'expo',
+ 'ios',
+ 'ClerkViewFactory.swift',
+ ),
+ // Alternative monorepo structure
+ path.join(config.modRequest.projectRoot, '..', 'packages', 'expo', 'ios', 'ClerkViewFactory.swift'),
+ ];
+
+ for (const possiblePath of possiblePaths) {
+ if (fs.existsSync(possiblePath)) {
+ sourceFile = possiblePath;
+ break;
+ }
+ }
+
+ if (sourceFile && fs.existsSync(sourceFile)) {
+ // ALWAYS copy the file to ensure we have the latest version
+ const targetFile = path.join(iosProjectPath, 'ClerkViewFactory.swift');
+ fs.copyFileSync(sourceFile, targetFile);
+ console.log('✅ Copied ClerkViewFactory.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';
+
+ try {
+ // Get the main target
+ const target = xcodeProject.getFirstTarget();
+ if (!target || !target.uuid) {
+ console.warn('⚠️ Could not find target UUID, file copied but not added to project');
+ return config;
+ }
+
+ const targetUuid = target.uuid;
+
+ // Check if file is already in the Xcode project references
+ const fileReferences = xcodeProject.hash.project.objects.PBXFileReference || {};
+ const alreadyExists = Object.values(fileReferences).some(ref => ref && ref.path === fileName);
+
+ if (alreadyExists) {
+ // File is already in project, but we still copied the latest version
+ console.log('✅ ClerkViewFactory.swift updated in app target');
+ return config;
+ }
+
+ // 1. Create PBXFileReference
+ const fileRefUuid = xcodeProject.generateUuid();
+ if (!xcodeProject.hash.project.objects.PBXFileReference) {
+ xcodeProject.hash.project.objects.PBXFileReference = {};
+ }
+
+ xcodeProject.hash.project.objects.PBXFileReference[fileRefUuid] = {
+ isa: 'PBXFileReference',
+ lastKnownFileType: 'sourcecode.swift',
+ name: fileName,
+ path: relativePath, // Use full relative path (projectName/ClerkViewFactory.swift)
+ sourceTree: '""',
+ };
+
+ // 2. Create PBXBuildFile
+ const buildFileUuid = xcodeProject.generateUuid();
+ if (!xcodeProject.hash.project.objects.PBXBuildFile) {
+ xcodeProject.hash.project.objects.PBXBuildFile = {};
+ }
+
+ xcodeProject.hash.project.objects.PBXBuildFile[buildFileUuid] = {
+ isa: 'PBXBuildFile',
+ fileRef: fileRefUuid,
+ fileRef_comment: fileName,
+ };
+
+ // 3. Add to PBXSourcesBuildPhase
+ const buildPhases = xcodeProject.hash.project.objects.PBXSourcesBuildPhase || {};
+ let sourcesPhaseUuid = null;
+
+ // Find the sources build phase for the main target
+ const nativeTarget = xcodeProject.hash.project.objects.PBXNativeTarget[targetUuid];
+ if (nativeTarget && nativeTarget.buildPhases) {
+ for (const phase of nativeTarget.buildPhases) {
+ if (buildPhases[phase.value] && buildPhases[phase.value].isa === 'PBXSourcesBuildPhase') {
+ sourcesPhaseUuid = phase.value;
+ break;
+ }
+ }
+ }
+
+ if (sourcesPhaseUuid && buildPhases[sourcesPhaseUuid]) {
+ if (!buildPhases[sourcesPhaseUuid].files) {
+ buildPhases[sourcesPhaseUuid].files = [];
+ }
+
+ buildPhases[sourcesPhaseUuid].files.push({
+ value: buildFileUuid,
+ comment: fileName,
+ });
+ } else {
+ console.warn('⚠️ Could not find PBXSourcesBuildPhase for target');
+ }
+
+ // 4. Add to PBXGroup (main group for the project)
+ const groups = xcodeProject.hash.project.objects.PBXGroup || {};
+ let mainGroupUuid = null;
+
+ // Find the group with the same name as the project
+ for (const [uuid, group] of Object.entries(groups)) {
+ if (group && group.name === projectName) {
+ mainGroupUuid = uuid;
+ break;
+ }
+ }
+
+ if (mainGroupUuid && groups[mainGroupUuid]) {
+ if (!groups[mainGroupUuid].children) {
+ groups[mainGroupUuid].children = [];
+ }
+
+ // Add file reference to the group
+ groups[mainGroupUuid].children.push({
+ value: fileRefUuid,
+ comment: fileName,
+ });
+ } else {
+ console.warn('⚠️ Could not find main PBXGroup for project');
+ }
+
+ console.log('✅ Added ClerkViewFactory.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');
+ }
+ } catch (error) {
+ console.error('❌ Error injecting ClerkViewFactory.swift:', error.message);
+ }
+
+ return config;
+ });
+
+ // Inject SPM package resolution into Podfile post_install hook
+ // This runs synchronously during pod install, ensuring packages are resolved before prebuild completes
+ config = withDangerousMod(config, [
+ 'ios',
+ async config => {
+ const platformProjectRoot = config.modRequest.platformProjectRoot;
+ const projectName = config.modRequest.projectName;
+ const podfilePath = path.join(platformProjectRoot, 'Podfile');
+
+ if (fs.existsSync(podfilePath)) {
+ let podfileContents = fs.readFileSync(podfilePath, 'utf8');
+
+ // Check if we've already added our resolution code
+ if (!podfileContents.includes('# Clerk: Resolve SPM packages')) {
+ // Code to inject into existing post_install block
+ // Note: We run this AFTER react_native_post_install to ensure the workspace is fully written
+ const spmResolutionCode = `
+ # Clerk: Resolve SPM packages synchronously during pod install
+ # This ensures packages are downloaded before the user opens Xcode
+ # We wait until the end of post_install to ensure workspace is fully written
+ at_exit do
+ workspace_path = File.join(__dir__, '${projectName}.xcworkspace')
+ if File.exist?(workspace_path)
+ puts ""
+ puts "📦 [Clerk] Resolving Swift Package dependencies..."
+ puts " This may take a minute on first run..."
+ # Use backticks to capture output and check exit status
+ output = \`xcodebuild -resolvePackageDependencies -workspace "#{workspace_path}" -scheme "${projectName}" 2>&1\`
+ if $?.success?
+ puts "✅ [Clerk] Swift Package dependencies resolved successfully"
+ else
+ puts "⚠️ [Clerk] SPM resolution output:"
+ puts output.lines.last(10).join
+ end
+ puts ""
+ end
+ end
+`;
+
+ // Insert our code at the beginning of the existing post_install block
+ if (podfileContents.includes('post_install do |installer|')) {
+ podfileContents = podfileContents.replace(
+ /post_install do \|installer\|/,
+ `post_install do |installer|${spmResolutionCode}`,
+ );
+ fs.writeFileSync(podfilePath, podfileContents);
+ console.log('✅ Added SPM resolution to Podfile post_install hook');
+ }
+ }
+ }
+
+ return config;
+ },
+ ]);
+
+ return config;
+};
+
+/**
+ * Add packaging exclusions to Android app build.gradle to resolve
+ * duplicate META-INF file conflicts from clerk-android dependencies.
+ */
+const withClerkAndroid = config => {
+ console.log('✅ Clerk Android plugin loaded');
+
+ return withAppBuildGradle(config, modConfig => {
+ let buildGradle = modConfig.modResults.contents;
+
+ // Check if exclusion already exists
+ if (buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) {
+ console.log('✅ Clerk Android packaging exclusions already configured');
+ return modConfig;
+ }
+
+ // Find the existing packagingOptions block and add resources.excludes
+ const packagingOptionsMatch = buildGradle.match(/packagingOptions\s*\{/);
+ if (packagingOptionsMatch) {
+ // Add resources block inside packagingOptions
+ const resourcesExclude = `packagingOptions {
+ // Clerk Android SDK: exclude duplicate META-INF files
+ resources {
+ excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
+ }`;
+
+ buildGradle = buildGradle.replace(/packagingOptions\s*\{/, resourcesExclude);
+ modConfig.modResults.contents = buildGradle;
+ console.log('✅ Clerk Android packaging exclusions added');
+ } else {
+ console.warn('⚠️ Could not find packagingOptions block in build.gradle');
+ }
+
+ return modConfig;
+ });
+};
+
+/**
+ * Add Google Sign-In URL scheme to Info.plist (from main branch)
+ */
+const withClerkGoogleSignIn = config => {
+ const iosUrlScheme =
+ process.env.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME ||
+ (config.extra && config.extra.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME);
+
+ if (!iosUrlScheme) {
+ return config;
+ }
+
+ return withInfoPlist(config, modConfig => {
+ if (!Array.isArray(modConfig.modResults.CFBundleURLTypes)) {
+ modConfig.modResults.CFBundleURLTypes = [];
+ }
+
+ const schemeExists = modConfig.modResults.CFBundleURLTypes.some(urlType =>
+ urlType.CFBundleURLSchemes?.includes(iosUrlScheme),
+ );
+
+ if (!schemeExists) {
+ modConfig.modResults.CFBundleURLTypes.push({
+ CFBundleURLSchemes: [iosUrlScheme],
+ });
+ console.log(`✅ Added Google Sign-In URL scheme: ${iosUrlScheme}`);
+ }
+
+ return modConfig;
+ });
+};
+
+/**
+ * Combined Clerk Expo plugin
+ *
+ * When this plugin is configured in app.json/app.config.js:
+ * 1. Native modules are enabled (expo-module.config.json is updated)
+ * 2. iOS gets Swift Package Manager dependency for clerk-ios SDK
+ * 3. Android gets packaging exclusions for dependency conflicts
+ * 4. Google Sign-In URL scheme is configured (if env var is set)
+ */
+const withClerkExpo = config => {
+ // FIRST: Enable native modules by writing the full expo-module.config.json
+ // This must happen before autolinking scans for native modules
+ config = withClerkNativeModules(config);
+
+ // Then configure platform-specific settings
+ config = withClerkIOS(config);
+ config = withClerkGoogleSignIn(config);
+ config = withClerkAndroid(config);
+ return config;
+};
+
+module.exports = withClerkExpo;
diff --git a/packages/expo/expo-module.config.json b/packages/expo/expo-module.config.json
index e59f14eef13..e7dd48fca2f 100644
--- a/packages/expo/expo-module.config.json
+++ b/packages/expo/expo-module.config.json
@@ -1,9 +1,9 @@
{
- "platforms": ["android", "ios"],
- "android": {
- "modules": ["expo.modules.clerk.googlesignin.ClerkGoogleSignInModule"]
+ "platforms": ["apple", "android"],
+ "apple": {
+ "modules": ["ClerkExpoModule", "ClerkGoogleSignInModule"]
},
- "ios": {
- "modules": ["ClerkGoogleSignInModule"]
+ "android": {
+ "modules": ["expo.modules.clerk.ClerkExpoModule", "expo.modules.clerk.googlesignin.ClerkGoogleSignInModule"]
}
}
diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec
new file mode 100644
index 00000000000..acbff064b51
--- /dev/null
+++ b/packages/expo/ios/ClerkExpo.podspec
@@ -0,0 +1,43 @@
+require 'json'
+
+# Find package.json by following symlinks if necessary
+package_json_path = File.join(__dir__, '..', 'package.json')
+package_json_path = File.join(File.readlink(__dir__), '..', 'package.json') if File.symlink?(__dir__)
+
+# Fallback to hardcoded values if package.json is not found
+if File.exist?(package_json_path)
+ package = JSON.parse(File.read(package_json_path))
+else
+ package = {
+ 'version' => '2.16.1',
+ 'description' => 'Clerk React Native/Expo library',
+ 'license' => 'MIT',
+ 'author' => 'Clerk',
+ 'homepage' => 'https://clerk.com/'
+ }
+end
+
+Pod::Spec.new do |s|
+ s.name = 'ClerkExpo'
+ s.version = package['version']
+ s.summary = package['description']
+ s.license = package['license']
+ s.author = package['author']
+ s.homepage = package['homepage']
+ s.platforms = { :ios => '17.0' } # Clerk iOS SDK requires iOS 17
+ s.swift_version = '5.10'
+ s.source = { git: 'https://github.com/clerk/javascript' }
+ s.static_framework = true
+
+ s.dependency 'ExpoModulesCore'
+
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
+ }
+
+ # Only include the minimal module file in the pod.
+ # ClerkViewFactory.swift (with views) is injected into the app target by the config plugin
+ # because it uses `import Clerk` which is only available via SPM in the app target.
+ s.source_files = "ClerkExpoModule.swift"
+end
diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift
new file mode 100644
index 00000000000..a9521fefe29
--- /dev/null
+++ b/packages/expo/ios/ClerkExpoModule.swift
@@ -0,0 +1,296 @@
+// 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 Expo views)
+// because the Clerk SDK (SPM) isn't accessible from CocoaPods.
+
+import ExpoModulesCore
+import UIKit
+
+// Global registry for the Clerk view factory (set by app target at startup)
+public var clerkViewFactory: ClerkViewFactoryProtocol?
+
+// Protocol that the app target implements to provide Clerk views
+public protocol ClerkViewFactoryProtocol {
+ // Modal presentation (existing)
+ func createAuthViewController(mode: String, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) -> UIViewController?
+ func createUserProfileViewController(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) -> UIViewController?
+
+ // Inline rendering (new) — 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?
+
+ // SDK operations
+ func configure(publishableKey: String) async throws
+ func getSession() async -> [String: Any]?
+ func signOut() async throws
+}
+
+// MARK: - Inline ExpoView for AuthView
+
+public class ClerkAuthExpoView: ExpoView {
+ private var hostingController: UIViewController?
+ var currentMode: String = "signInOrUp"
+ var currentDismissable: Bool = true
+ private var hasInitialized: Bool = false
+
+ let onAuthEvent = EventDispatcher()
+
+ func updateView() {
+ hasInitialized = true
+ // Remove old hosting controller
+ hostingController?.view.removeFromSuperview()
+ hostingController?.removeFromParent()
+ hostingController = nil
+
+ guard let factory = clerkViewFactory else { return }
+
+ guard let returnedController = factory.createAuthView(
+ mode: currentMode,
+ dismissable: currentDismissable,
+ onEvent: { [weak self] eventName, data in
+ self?.onAuthEvent([
+ "type": eventName,
+ "data": data
+ ])
+ }
+ ) else { return }
+
+ // Attach the returned UIHostingController as a child to preserve SwiftUI lifecycle
+ 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
+ }
+
+ public override func layoutSubviews() {
+ super.layoutSubviews()
+ hostingController?.view.frame = bounds
+ }
+}
+
+// MARK: - Inline ExpoView for UserProfileView
+
+public class ClerkUserProfileExpoView: ExpoView {
+ private var hostingController: UIViewController?
+ var currentDismissable: Bool = true
+ private var hasInitialized: Bool = false
+
+ let onProfileEvent = EventDispatcher()
+
+ func updateView() {
+ hasInitialized = true
+ // 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
+ self?.onProfileEvent([
+ "type": eventName,
+ "data": data
+ ])
+ }
+ ) 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
+ }
+
+ public override func layoutSubviews() {
+ super.layoutSubviews()
+ hostingController?.view.frame = bounds
+ }
+}
+
+// MARK: - Module Definition
+
+public class ClerkExpoModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("ClerkExpo")
+
+ // Configure Clerk with publishable key
+ AsyncFunction("configure") { (publishableKey: String) in
+ guard let factory = clerkViewFactory else {
+ throw NSError(domain: "ClerkExpo", code: 1, userInfo: [NSLocalizedDescriptionKey: "Clerk not initialized. Make sure ClerkViewFactory is registered."])
+ }
+ try await factory.configure(publishableKey: publishableKey)
+ }
+
+ // Present sign-in/sign-up modal
+ AsyncFunction("presentAuth") { (options: [String: Any]) -> [String: Any] in
+ guard let factory = clerkViewFactory else {
+ throw NSError(domain: "ClerkExpo", code: 1, userInfo: [NSLocalizedDescriptionKey: "Clerk not initialized"])
+ }
+
+ // Always present the auth modal - let the native UI handle signed-in state
+ // The JS SDK will check isSignedIn before calling this
+ let mode = options["mode"] as? String ?? "signInOrUp"
+ let dismissable = options["dismissable"] as? Bool ?? true
+
+ return try await withCheckedThrowingContinuation { continuation in
+ DispatchQueue.main.async {
+ guard let vc = factory.createAuthViewController(mode: mode, dismissable: dismissable, completion: { result in
+ switch result {
+ case .success(let data):
+ continuation.resume(returning: data)
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }) else {
+ continuation.resume(throwing: NSError(domain: "ClerkExpo", code: 2, userInfo: [NSLocalizedDescriptionKey: "Could not create auth view controller"]))
+ return
+ }
+
+ if let rootVC = UIApplication.shared.keyWindow?.rootViewController {
+ rootVC.present(vc, animated: true)
+ } else {
+ continuation.resume(throwing: NSError(domain: "ClerkExpo", code: 3, userInfo: [NSLocalizedDescriptionKey: "No root view controller available to present auth"]))
+ }
+ }
+ }
+ }
+
+ // Present user profile modal
+ AsyncFunction("presentUserProfile") { (options: [String: Any]) -> [String: Any] in
+ guard let factory = clerkViewFactory else {
+ throw NSError(domain: "ClerkExpo", code: 1, userInfo: [NSLocalizedDescriptionKey: "Clerk not initialized"])
+ }
+
+ let dismissable = options["dismissable"] as? Bool ?? true
+
+ return try await withCheckedThrowingContinuation { continuation in
+ DispatchQueue.main.async {
+ guard let vc = factory.createUserProfileViewController(dismissable: dismissable, completion: { result in
+ switch result {
+ case .success(let data):
+ continuation.resume(returning: data)
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }) else {
+ continuation.resume(throwing: NSError(domain: "ClerkExpo", code: 2, userInfo: [NSLocalizedDescriptionKey: "Could not create profile view controller"]))
+ return
+ }
+
+ if let rootVC = UIApplication.shared.keyWindow?.rootViewController {
+ rootVC.present(vc, animated: true)
+ } else {
+ continuation.resume(throwing: NSError(domain: "ClerkExpo", code: 3, userInfo: [NSLocalizedDescriptionKey: "No root view controller available to present profile"]))
+ }
+ }
+ }
+ }
+
+ // Get current session from native Clerk SDK
+ AsyncFunction("getSession") { () -> [String: Any]? in
+ guard let factory = clerkViewFactory else {
+ return nil
+ }
+ return await factory.getSession()
+ }
+
+ // Get the native Clerk client's bearer token from the iOS keychain
+ // This allows the JS SDK to use the same client as the native SDK
+ AsyncFunction("getClientToken") { () -> String? in
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: Bundle.main.bundleIdentifier ?? "",
+ kSecAttrAccount as String: "clerkDeviceToken",
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+
+ if status == errSecSuccess, let data = result as? Data {
+ return String(data: data, encoding: .utf8)
+ }
+ return nil
+ }
+
+ // Sign out from native Clerk SDK
+ AsyncFunction("signOut") { () in
+ guard let factory = clerkViewFactory else {
+ throw NSError(domain: "ClerkExpo", code: 1, userInfo: [NSLocalizedDescriptionKey: "Clerk not initialized"])
+ }
+ try await factory.signOut()
+ }
+
+ // MARK: - Inline Native Views
+
+ View(ClerkAuthExpoView.self) {
+ Events("onAuthEvent")
+
+ Prop("mode") { (view: ClerkAuthExpoView, mode: String?) in
+ view.currentMode = mode ?? "signInOrUp"
+ }
+
+ Prop("isDismissable") { (view: ClerkAuthExpoView, dismissable: Bool?) in
+ view.currentDismissable = dismissable ?? true
+ }
+
+ OnViewDidUpdateProps { view in
+ view.updateView()
+ }
+ }
+
+ View(ClerkUserProfileExpoView.self) {
+ Events("onProfileEvent")
+
+ Prop("isDismissable") { (view: ClerkUserProfileExpoView, dismissable: Bool?) in
+ view.currentDismissable = dismissable ?? true
+ }
+
+ OnViewDidUpdateProps { view in
+ view.updateView()
+ }
+ }
+ }
+}
diff --git a/packages/expo/ios/ClerkGoogleSignIn.podspec b/packages/expo/ios/ClerkGoogleSignIn.podspec
index be0f3551b2b..1630ce877de 100644
--- a/packages/expo/ios/ClerkGoogleSignIn.podspec
+++ b/packages/expo/ios/ClerkGoogleSignIn.podspec
@@ -18,5 +18,6 @@ Pod::Spec.new do |s|
s.dependency 'ExpoModulesCore'
s.dependency 'GoogleSignIn', '~> 9.0'
- s.source_files = '*.swift'
+ # Only include the Google Sign-In module file, not ClerkViewFactory.swift which uses `import Clerk` (SPM)
+ s.source_files = 'ClerkGoogleSignInModule.swift'
end
diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift
new file mode 100644
index 00000000000..eaa7a7e26aa
--- /dev/null
+++ b/packages/expo/ios/ClerkViewFactory.swift
@@ -0,0 +1,338 @@
+// ClerkViewFactory - Provides Clerk view controllers to the ClerkExpo module
+// This file is injected into the app target by the config plugin.
+// It uses `import Clerk` (SPM) which is only accessible from the app target.
+
+import UIKit
+import SwiftUI
+import Clerk
+import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol
+
+// MARK: - View Factory Implementation
+
+public class ClerkViewFactory: ClerkViewFactoryProtocol {
+ public static let shared = ClerkViewFactory()
+
+ private init() {}
+
+ // Register this factory with the ClerkExpo module
+ public static func register() {
+ clerkViewFactory = shared
+ print("✅ [ClerkViewFactory] Registered with ClerkExpo module")
+ }
+
+ @MainActor
+ public func configure(publishableKey: String) async throws {
+ print("🔧 [ClerkViewFactory] Configuring Clerk with key: \(publishableKey.prefix(20))...")
+ Clerk.shared.configure(publishableKey: publishableKey)
+ print("✅ [ClerkViewFactory] Clerk configured, now loading...")
+
+ // CRITICAL: Must call load() after configure() to restore session from keychain
+ do {
+ try await Clerk.shared.load()
+ print("✅ [ClerkViewFactory] Clerk load() completed")
+ } catch {
+ print("❌ [ClerkViewFactory] Clerk load() failed: \(error)")
+ }
+
+ // IMPORTANT: load() is async but session may be populated AFTER it returns
+ // The SDK uses Combine/ObservableObject pattern - session is published asynchronously
+ // We need to wait for the session to actually be populated
+ print("⏳ [ClerkViewFactory] Waiting for session to be populated...")
+ for i in 0..<30 { // Wait up to 3 seconds
+ if Clerk.shared.session != nil {
+ print("✅ [ClerkViewFactory] Session found after \(i * 100)ms: \(Clerk.shared.session?.id ?? "unknown")")
+ return
+ }
+ try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
+ }
+ print("⚠️ [ClerkViewFactory] No session found after 3s, session: \(Clerk.shared.session?.id ?? "none")")
+ }
+
+ public func createAuthViewController(
+ mode: String,
+ dismissable: Bool,
+ completion: @escaping (Result<[String: Any], Error>) -> Void
+ ) -> UIViewController? {
+ let authMode: AuthView.Mode
+ switch mode {
+ case "signIn":
+ authMode = .signIn
+ case "signUp":
+ authMode = .signUp
+ default:
+ authMode = .signInOrUp
+ }
+
+ let wrapper = ClerkAuthWrapperViewController(
+ mode: authMode,
+ dismissable: dismissable,
+ completion: completion
+ )
+ return wrapper
+ }
+
+ public func createUserProfileViewController(
+ dismissable: Bool,
+ completion: @escaping (Result<[String: Any], Error>) -> Void
+ ) -> UIViewController? {
+ let wrapper = ClerkProfileWrapperViewController(
+ dismissable: dismissable,
+ completion: completion
+ )
+ return wrapper
+ }
+
+ // MARK: - Inline View Creation
+
+ public func createAuthView(
+ mode: String,
+ dismissable: Bool,
+ onEvent: @escaping (String, [String: Any]) -> Void
+ ) -> UIView? {
+ let authMode: AuthView.Mode
+ switch mode {
+ case "signIn":
+ authMode = .signIn
+ case "signUp":
+ authMode = .signUp
+ default:
+ authMode = .signInOrUp
+ }
+
+ let hostingController = UIHostingController(
+ rootView: ClerkInlineAuthWrapperView(
+ mode: authMode,
+ dismissable: dismissable,
+ onEvent: onEvent
+ )
+ )
+ hostingController.view.backgroundColor = .clear
+ return hostingController.view
+ }
+
+ public func createUserProfileView(
+ dismissable: Bool,
+ onEvent: @escaping (String, [String: Any]) -> Void
+ ) -> UIView? {
+ let hostingController = UIHostingController(
+ rootView: ClerkInlineProfileWrapperView(
+ dismissable: dismissable,
+ onEvent: onEvent
+ )
+ )
+ hostingController.view.backgroundColor = .clear
+ return hostingController.view
+ }
+
+ @MainActor
+ public func getSession() async -> [String: Any]? {
+ guard let session = Clerk.shared.session else {
+ print("📭 [ClerkViewFactory] No active session")
+ return nil
+ }
+ print("✅ [ClerkViewFactory] Found active session: \(session.id)")
+
+ var result: [String: Any] = [
+ "sessionId": session.id,
+ "status": String(describing: session.status)
+ ]
+
+ // Include user details if available
+ // Try to get user from session first, then fallback to Clerk.shared.user
+ let user = session.user ?? Clerk.shared.user
+ NSLog("🔍 [ClerkViewFactory] Clerk.shared.user: \(Clerk.shared.user?.id ?? "nil")")
+ NSLog("🔍 [ClerkViewFactory] session.user: \(session.user?.id ?? "nil")")
+
+ if let user = user {
+ var userDict: [String: Any] = [
+ "id": user.id,
+ "imageUrl": user.imageUrl
+ ]
+ if let firstName = user.firstName {
+ userDict["firstName"] = firstName
+ }
+ if let lastName = user.lastName {
+ userDict["lastName"] = lastName
+ }
+ if let primaryEmail = user.emailAddresses.first(where: { $0.id == user.primaryEmailAddressId }) {
+ userDict["primaryEmailAddress"] = primaryEmail.emailAddress
+ } else if let firstEmail = user.emailAddresses.first {
+ userDict["primaryEmailAddress"] = firstEmail.emailAddress
+ }
+ result["user"] = userDict
+ NSLog("✅ [ClerkViewFactory] User found: \(user.firstName ?? "N/A") \(user.lastName ?? "")")
+ } else {
+ NSLog("⚠️ [ClerkViewFactory] No user available - all sources returned nil")
+ }
+
+ return result
+ }
+
+ public func signOut() async throws {
+ print("🔓 [ClerkViewFactory] Signing out...")
+ try await Clerk.shared.signOut()
+ print("✅ [ClerkViewFactory] Signed out successfully")
+ }
+}
+
+// MARK: - Auth View Controller Wrapper
+
+class ClerkAuthWrapperViewController: UIHostingController {
+ private let completion: (Result<[String: Any], Error>) -> Void
+ private var authEventTask: Task?
+
+ init(mode: AuthView.Mode, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
+ self.completion = completion
+ let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable)
+ 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()
+ }
+
+ private func subscribeToAuthEvents() {
+ authEventTask = Task { @MainActor [weak self] in
+ for await event in Clerk.shared.authEventEmitter.events {
+ guard let self = self else { return }
+ switch event {
+ case .signInCompleted(let signIn):
+ if let sessionId = signIn.createdSessionId {
+ self.completion(.success(["sessionId": sessionId, "type": "signIn"]))
+ self.dismiss(animated: true)
+ } else {
+ self.completion(.failure(NSError(domain: "ClerkExpo", code: 4, userInfo: [NSLocalizedDescriptionKey: "Sign-in completed but no session was created"])))
+ self.dismiss(animated: true)
+ }
+ case .signUpCompleted(let signUp):
+ if let sessionId = signUp.createdSessionId {
+ self.completion(.success(["sessionId": sessionId, "type": "signUp"]))
+ self.dismiss(animated: true)
+ } else {
+ self.completion(.failure(NSError(domain: "ClerkExpo", code: 4, userInfo: [NSLocalizedDescriptionKey: "Sign-up completed but no session was created"])))
+ self.dismiss(animated: true)
+ }
+ default:
+ break
+ }
+ }
+ // Stream ended without a completion event
+ guard let self = self else { return }
+ self.completion(.failure(NSError(domain: "ClerkExpo", code: 5, userInfo: [NSLocalizedDescriptionKey: "Auth event stream ended unexpectedly"])))
+ }
+ }
+}
+
+struct ClerkAuthWrapperView: View {
+ let mode: AuthView.Mode
+ let dismissable: Bool
+
+ var body: some View {
+ AuthView(mode: mode, isDismissable: dismissable)
+ }
+}
+
+// MARK: - Profile View Controller Wrapper
+
+class ClerkProfileWrapperViewController: UIHostingController {
+ private let completion: (Result<[String: Any], Error>) -> Void
+ private var authEventTask: Task?
+
+ init(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
+ self.completion = completion
+ let view = ClerkProfileWrapperView(dismissable: dismissable)
+ 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()
+ }
+
+ private func subscribeToAuthEvents() {
+ authEventTask = Task { @MainActor [weak self] in
+ for await event in Clerk.shared.authEventEmitter.events {
+ guard let self = self else { return }
+ switch event {
+ case .signedOut(let session):
+ self.completion(.success(["sessionId": session.id]))
+ self.dismiss(animated: true)
+ default:
+ break
+ }
+ }
+ // Stream ended without a sign-out event
+ guard let self = self else { return }
+ self.completion(.failure(NSError(domain: "ClerkExpo", code: 5, userInfo: [NSLocalizedDescriptionKey: "Profile event stream ended unexpectedly"])))
+ }
+ }
+}
+
+struct ClerkProfileWrapperView: View {
+ let dismissable: Bool
+
+ var body: some View {
+ UserProfileView(isDismissable: dismissable)
+ }
+}
+
+// MARK: - Inline Auth View Wrapper (for embedded rendering)
+
+struct ClerkInlineAuthWrapperView: View {
+ let mode: AuthView.Mode
+ let dismissable: Bool
+ let onEvent: (String, [String: Any]) -> Void
+
+ var body: some View {
+ AuthView(mode: mode, isDismissable: dismissable)
+ .task {
+ for await event in Clerk.shared.authEventEmitter.events {
+ switch event {
+ case .signInCompleted(let signIn):
+ if let sessionId = signIn.createdSessionId {
+ onEvent("signInCompleted", ["sessionId": sessionId, "type": "signIn"])
+ }
+ case .signUpCompleted(let signUp):
+ if let sessionId = signUp.createdSessionId {
+ onEvent("signUpCompleted", ["sessionId": sessionId, "type": "signUp"])
+ }
+ default:
+ break
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Inline Profile View Wrapper (for embedded rendering)
+
+struct ClerkInlineProfileWrapperView: View {
+ let dismissable: Bool
+ let onEvent: (String, [String: Any]) -> Void
+
+ var body: some View {
+ UserProfileView(isDismissable: dismissable)
+ .task {
+ for await event in Clerk.shared.authEventEmitter.events {
+ switch event {
+ case .signedOut(let session):
+ onEvent("signedOut", ["sessionId": session.id])
+ default:
+ break
+ }
+ }
+ }
+ }
+}
+
diff --git a/packages/expo/ios/templates/ClerkViewFactory.swift b/packages/expo/ios/templates/ClerkViewFactory.swift
new file mode 100644
index 00000000000..191e50d06e1
--- /dev/null
+++ b/packages/expo/ios/templates/ClerkViewFactory.swift
@@ -0,0 +1,341 @@
+// ClerkViewFactory - Provides Clerk view controllers to the ClerkExpo module
+// This file is injected into the app target by the config plugin.
+// It uses `import Clerk` (SPM) which is only accessible from the app target.
+
+import UIKit
+import SwiftUI
+import Clerk
+import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol
+
+// MARK: - View Factory Implementation
+
+public class ClerkViewFactory: ClerkViewFactoryProtocol {
+ public static let shared = ClerkViewFactory()
+
+ private init() {}
+
+ // Register this factory with the ClerkExpo module
+ public static func register() {
+ clerkViewFactory = shared
+ print("✅ [ClerkViewFactory] Registered with ClerkExpo module")
+ }
+
+ @MainActor
+ public func configure(publishableKey: String) async throws {
+ print("🔧 [ClerkViewFactory] Configuring Clerk with key: \(publishableKey.prefix(20))...")
+ Clerk.shared.configure(publishableKey: publishableKey)
+ print("✅ [ClerkViewFactory] Clerk configured, now loading...")
+
+ // CRITICAL: Must call load() after configure() to restore session from keychain
+ do {
+ try await Clerk.shared.load()
+ print("✅ [ClerkViewFactory] Clerk load() completed")
+ } catch {
+ print("❌ [ClerkViewFactory] Clerk load() failed: \(error)")
+ }
+
+ // IMPORTANT: load() is async but session may be populated AFTER it returns
+ // The SDK uses Combine/ObservableObject pattern - session is published asynchronously
+ // We need to wait for the session to actually be populated
+ print("⏳ [ClerkViewFactory] Waiting for session to be populated...")
+ for i in 0..<30 { // Wait up to 3 seconds
+ if Clerk.shared.session != nil {
+ print("✅ [ClerkViewFactory] Session found after \(i * 100)ms: \(Clerk.shared.session?.id ?? "unknown")")
+ return
+ }
+ try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
+ }
+ print("⚠️ [ClerkViewFactory] No session found after 3s, session: \(Clerk.shared.session?.id ?? "none")")
+ }
+
+ public func createAuthViewController(
+ mode: String,
+ dismissable: Bool,
+ completion: @escaping (Result<[String: Any], Error>) -> Void
+ ) -> UIViewController? {
+ let authMode: AuthView.Mode
+ switch mode {
+ case "signIn":
+ authMode = .signIn
+ case "signUp":
+ authMode = .signUp
+ default:
+ authMode = .signInOrUp
+ }
+
+ let wrapper = ClerkAuthWrapperViewController(
+ mode: authMode,
+ dismissable: dismissable,
+ completion: completion
+ )
+ return wrapper
+ }
+
+ public func createUserProfileViewController(
+ dismissable: Bool,
+ completion: @escaping (Result<[String: Any], Error>) -> Void
+ ) -> UIViewController? {
+ let wrapper = ClerkProfileWrapperViewController(
+ dismissable: dismissable,
+ completion: completion
+ )
+ return wrapper
+ }
+
+ // MARK: - Inline View Creation
+
+ public func createAuthView(
+ mode: String,
+ dismissable: Bool,
+ onEvent: @escaping (String, [String: Any]) -> Void
+ ) -> UIViewController? {
+ let authMode: AuthView.Mode
+ switch mode {
+ case "signIn":
+ authMode = .signIn
+ case "signUp":
+ authMode = .signUp
+ default:
+ authMode = .signInOrUp
+ }
+
+ let hostingController = UIHostingController(
+ rootView: ClerkInlineAuthWrapperView(
+ mode: authMode,
+ dismissable: dismissable,
+ onEvent: onEvent
+ )
+ )
+ hostingController.view.backgroundColor = .clear
+ return hostingController
+ }
+
+ public func createUserProfileView(
+ dismissable: Bool,
+ onEvent: @escaping (String, [String: Any]) -> Void
+ ) -> UIViewController? {
+ let hostingController = UIHostingController(
+ rootView: ClerkInlineProfileWrapperView(
+ dismissable: dismissable,
+ onEvent: onEvent
+ )
+ )
+ hostingController.view.backgroundColor = .clear
+ return hostingController
+ }
+
+ @MainActor
+ public func getSession() async -> [String: Any]? {
+ guard let session = Clerk.shared.session else {
+ print("📭 [ClerkViewFactory] No active session")
+ return nil
+ }
+ print("✅ [ClerkViewFactory] Found active session: \(session.id)")
+
+ var result: [String: Any] = [
+ "sessionId": session.id,
+ "status": String(describing: session.status)
+ ]
+
+ // Include user details if available
+ // Try to get user from session first, then fallback to Clerk.shared.user
+ let user = session.user ?? Clerk.shared.user
+ NSLog("🔍 [ClerkViewFactory] Clerk.shared.user: \(Clerk.shared.user?.id ?? "nil")")
+ NSLog("🔍 [ClerkViewFactory] session.user: \(session.user?.id ?? "nil")")
+
+ if let user = user {
+ var userDict: [String: Any] = [
+ "id": user.id,
+ "imageUrl": user.imageUrl
+ ]
+ if let firstName = user.firstName {
+ userDict["firstName"] = firstName
+ }
+ if let lastName = user.lastName {
+ userDict["lastName"] = lastName
+ }
+ if let primaryEmail = user.emailAddresses.first(where: { $0.id == user.primaryEmailAddressId }) {
+ userDict["primaryEmailAddress"] = primaryEmail.emailAddress
+ } else if let firstEmail = user.emailAddresses.first {
+ userDict["primaryEmailAddress"] = firstEmail.emailAddress
+ }
+ result["user"] = userDict
+ NSLog("✅ [ClerkViewFactory] User found: \(user.firstName ?? "N/A") \(user.lastName ?? "")")
+ } else {
+ NSLog("⚠️ [ClerkViewFactory] No user available - all sources returned nil")
+ }
+
+ return result
+ }
+
+ public func signOut() async throws {
+ print("🔓 [ClerkViewFactory] Signing out...")
+ try await Clerk.shared.signOut()
+ print("✅ [ClerkViewFactory] Signed out successfully")
+ }
+}
+
+// MARK: - Auth View Controller Wrapper
+
+class ClerkAuthWrapperViewController: UIHostingController {
+ private let completion: (Result<[String: Any], Error>) -> Void
+ private var authEventTask: Task?
+
+ init(mode: AuthView.Mode, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
+ self.completion = completion
+ let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable)
+ 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()
+ }
+
+ private func subscribeToAuthEvents() {
+ authEventTask = Task { @MainActor [weak self] in
+ for await event in Clerk.shared.authEventEmitter.events {
+ guard let self = self else { return }
+ switch event {
+ case .signInCompleted(let signIn):
+ print("✅ [ClerkAuth] Sign-in completed")
+ if let sessionId = signIn.createdSessionId {
+ self.completion(.success(["sessionId": sessionId, "type": "signIn"]))
+ self.dismiss(animated: true)
+ } else {
+ self.completion(.failure(NSError(domain: "ClerkAuth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Sign-in completed but no session ID was created"])))
+ self.dismiss(animated: true)
+ }
+ case .signUpCompleted(let signUp):
+ print("✅ [ClerkAuth] Sign-up completed")
+ if let sessionId = signUp.createdSessionId {
+ self.completion(.success(["sessionId": sessionId, "type": "signUp"]))
+ self.dismiss(animated: true)
+ } else {
+ self.completion(.failure(NSError(domain: "ClerkAuth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Sign-up completed but no session ID was created"])))
+ self.dismiss(animated: true)
+ }
+ default:
+ break
+ }
+ }
+ // Stream ended without an auth completion event
+ guard let self = self else { return }
+ self.completion(.failure(NSError(domain: "ClerkAuth", code: 2, userInfo: [NSLocalizedDescriptionKey: "Auth event stream ended unexpectedly"])))
+ }
+ }
+}
+
+struct ClerkAuthWrapperView: View {
+ let mode: AuthView.Mode
+ let dismissable: Bool
+
+ var body: some View {
+ AuthView(mode: mode, isDismissable: dismissable)
+ }
+}
+
+// MARK: - Profile View Controller Wrapper
+
+class ClerkProfileWrapperViewController: UIHostingController {
+ private let completion: (Result<[String: Any], Error>) -> Void
+ private var authEventTask: Task?
+
+ init(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
+ self.completion = completion
+ let view = ClerkProfileWrapperView(dismissable: dismissable)
+ 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()
+ }
+
+ private func subscribeToAuthEvents() {
+ authEventTask = Task { @MainActor [weak self] in
+ for await event in Clerk.shared.authEventEmitter.events {
+ guard let self = self else { return }
+ switch event {
+ case .signedOut(let session):
+ print("✅ [ClerkProfile] Signed out")
+ self.completion(.success(["sessionId": session.id]))
+ self.dismiss(animated: true)
+ default:
+ break
+ }
+ }
+ // Stream ended without a sign-out event
+ guard let self = self else { return }
+ self.completion(.failure(NSError(domain: "ClerkProfile", code: 2, userInfo: [NSLocalizedDescriptionKey: "Profile event stream ended unexpectedly"])))
+ }
+ }
+}
+
+struct ClerkProfileWrapperView: View {
+ let dismissable: Bool
+
+ var body: some View {
+ UserProfileView(isDismissable: dismissable)
+ }
+}
+
+// MARK: - Inline Auth View Wrapper (for embedded rendering)
+
+struct ClerkInlineAuthWrapperView: View {
+ let mode: AuthView.Mode
+ let dismissable: Bool
+ let onEvent: (String, [String: Any]) -> Void
+
+ var body: some View {
+ AuthView(mode: mode, isDismissable: dismissable)
+ .task {
+ for await event in Clerk.shared.authEventEmitter.events {
+ switch event {
+ case .signInCompleted(let signIn):
+ if let sessionId = signIn.createdSessionId {
+ onEvent("signInCompleted", ["sessionId": sessionId, "type": "signIn"])
+ }
+ case .signUpCompleted(let signUp):
+ if let sessionId = signUp.createdSessionId {
+ onEvent("signUpCompleted", ["sessionId": sessionId, "type": "signUp"])
+ }
+ default:
+ break
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Inline Profile View Wrapper (for embedded rendering)
+
+struct ClerkInlineProfileWrapperView: View {
+ let dismissable: Bool
+ let onEvent: (String, [String: Any]) -> Void
+
+ var body: some View {
+ UserProfileView(isDismissable: dismissable)
+ .task {
+ for await event in Clerk.shared.authEventEmitter.events {
+ switch event {
+ case .signedOut(let session):
+ onEvent("signedOut", ["sessionId": session.id])
+ default:
+ break
+ }
+ }
+ }
+ }
+}
+
diff --git a/packages/expo/native/package.json b/packages/expo/native/package.json
new file mode 100644
index 00000000000..6ae24b71af4
--- /dev/null
+++ b/packages/expo/native/package.json
@@ -0,0 +1,4 @@
+{
+ "main": "../dist/native/index.js",
+ "types": "../dist/native/index.d.ts"
+}
diff --git a/packages/expo/package.json b/packages/expo/package.json
index d376f1a1eba..1d95346a2f4 100644
--- a/packages/expo/package.json
+++ b/packages/expo/package.json
@@ -1,6 +1,6 @@
{
"name": "@clerk/expo",
- "version": "2.19.10",
+ "version": "2.19.24",
"description": "Clerk React Native/Expo library",
"keywords": [
"react",
@@ -28,6 +28,11 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
+ "./app.plugin.js": "./app.plugin.js",
+ "./native": {
+ "types": "./dist/native/index.d.ts",
+ "default": "./dist/native/index.js"
+ },
"./web": {
"types": "./dist/web/index.d.ts",
"default": "./dist/web/index.js"
@@ -69,7 +74,7 @@
"default": "./dist/legacy.js"
},
"./types": "./dist/types/index.d.ts",
- "./app.plugin.js": "./app.plugin.js"
+ "./package.json": "./package.json"
},
"main": "./dist/index.js",
"source": "./src/index.ts",
@@ -78,6 +83,13 @@
"dist",
"android",
"ios",
+ "native",
+ "web",
+ "local-credentials",
+ "passkeys",
+ "secure-store",
+ "resource-cache",
+ "token-cache",
"google",
"apple",
"expo-module.config.json",
@@ -108,6 +120,7 @@
"@clerk/expo-passkeys": "workspace:*",
"@expo/config-plugins": "^54.0.4",
"@types/base-64": "^1.0.2",
+ "esbuild": "^0.19.0",
"expo-apple-authentication": "^7.2.4",
"expo-auth-session": "^5.4.0",
"expo-constants": "^18.0.0",
diff --git a/packages/expo/src/components/controlComponents.tsx b/packages/expo/src/components/controlComponents.tsx
index 5ef4f45e015..41dd2e12f40 100644
--- a/packages/expo/src/components/controlComponents.tsx
+++ b/packages/expo/src/components/controlComponents.tsx
@@ -1 +1,22 @@
+// Re-export control components from @clerk/react
+// These provide conditional rendering based on auth state
export { ClerkLoaded, ClerkLoading, Show } from '@clerk/react';
+
+import type { PropsWithChildren, ReactNode } from 'react';
+import { Show } from '@clerk/react';
+
+/**
+ * Render children only when the user is signed in.
+ * A convenience wrapper around ``.
+ */
+export function SignedIn({ children }: PropsWithChildren): ReactNode {
+ return {children};
+}
+
+/**
+ * Render children only when the user is signed out.
+ * A convenience wrapper around ``.
+ */
+export function SignedOut({ children }: PropsWithChildren): ReactNode {
+ return {children};
+}
diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts
index 0b64ce0c5be..8f9e13694ea 100644
--- a/packages/expo/src/hooks/index.ts
+++ b/packages/expo/src/hooks/index.ts
@@ -1,3 +1,4 @@
+// Re-export hooks that don't need type overrides
export {
useClerk,
useEmailLink,
@@ -15,3 +16,5 @@ export {
export * from './useSSO';
export * from './useOAuth';
export * from './useAuth';
+export * from './useNativeSession';
+export * from './useNativeAuthEvents';
diff --git a/packages/expo/src/hooks/useNativeAuthEvents.ts b/packages/expo/src/hooks/useNativeAuthEvents.ts
new file mode 100644
index 00000000000..260fd49db83
--- /dev/null
+++ b/packages/expo/src/hooks/useNativeAuthEvents.ts
@@ -0,0 +1,108 @@
+import { Platform, requireNativeModule } from 'expo-modules-core';
+import { useEffect, useState } from 'react';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Get the native module for event listening
+let ClerkExpo: ReturnType | null = null;
+
+if (isNativeSupported) {
+ try {
+ ClerkExpo = requireNativeModule('ClerkExpo');
+ } catch {
+ // Native module not available - plugin not configured
+ ClerkExpo = null;
+ }
+}
+
+/**
+ * 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(() => {
+ console.log(`[useNativeAuthEvents] INIT: isNativeSupported=${isNativeSupported}, ClerkExpo=${!!ClerkExpo}`);
+
+ if (!isNativeSupported || !ClerkExpo) {
+ console.log(`[useNativeAuthEvents] SKIP: Native not supported or ClerkExpo not available`);
+ return;
+ }
+
+ // Import EventEmitter dynamically to avoid issues when module isn't available
+ let subscription: { remove: () => void } | null = null;
+
+ try {
+ // expo-modules-core provides an EventEmitter class that wraps native module events
+ const { EventEmitter } = require('expo-modules-core');
+ console.log(`[useNativeAuthEvents] SETUP: Creating EventEmitter for ClerkExpo`);
+ const eventEmitter = new EventEmitter(ClerkExpo);
+
+ console.log(`[useNativeAuthEvents] LISTEN: Adding listener for 'onAuthStateChange' events`);
+ subscription = eventEmitter.addListener('onAuthStateChange', (event: NativeAuthStateEvent) => {
+ console.log('[useNativeAuthEvents] EVENT_RECEIVED:', JSON.stringify(event));
+ setNativeAuthState(event);
+ });
+ console.log(`[useNativeAuthEvents] LISTEN: Listener added successfully`);
+ } catch (error) {
+ console.log('[useNativeAuthEvents] ERROR: Could not set up event listener:', error);
+ }
+
+ return () => {
+ console.log(`[useNativeAuthEvents] CLEANUP: Removing event listener`);
+ subscription?.remove();
+ };
+ }, []);
+
+ return {
+ nativeAuthState,
+ isSupported: isNativeSupported && !!ClerkExpo,
+ };
+}
diff --git a/packages/expo/src/hooks/useNativeSession.ts b/packages/expo/src/hooks/useNativeSession.ts
new file mode 100644
index 00000000000..eeef1fb46dc
--- /dev/null
+++ b/packages/expo/src/hooks/useNativeSession.ts
@@ -0,0 +1,135 @@
+import { Platform } from 'expo-modules-core';
+import { useCallback, useEffect, useState } from 'react';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Native session data structure
+interface NativeSessionData {
+ session?: {
+ id: string;
+ };
+ user?: {
+ id: string;
+ firstName?: string;
+ lastName?: string;
+ imageUrl?: string;
+ primaryEmailAddress?: string;
+ };
+}
+
+// Get the native module (use optional require to avoid crash if not available)
+let ClerkExpo: {
+ getSession: () => Promise;
+} | null = null;
+
+if (isNativeSupported) {
+ try {
+ const { requireNativeModule } = require('expo-modules-core');
+ ClerkExpo = requireNativeModule('ClerkExpo');
+ } catch {
+ // Native module not available - this is expected when expo plugin is not installed
+ }
+}
+
+export interface UseNativeSessionReturn {
+ /**
+ * Whether the native module is available (expo plugin installed)
+ */
+ isAvailable: boolean;
+
+ /**
+ * Whether the native session check is still loading
+ */
+ isLoading: boolean;
+
+ /**
+ * Whether there is an active native session
+ */
+ isSignedIn: boolean;
+
+ /**
+ * The native session data, if available
+ */
+ session: NativeSessionData['session'] | null;
+
+ /**
+ * The native user data, if available
+ */
+ user: NativeSessionData['user'] | null;
+
+ /**
+ * Refresh the native session state
+ */
+ refresh: () => Promise;
+}
+
+/**
+ * Hook to access native SDK session state.
+ *
+ * This hook is only useful when the @clerk/expo native plugin is installed.
+ * Without the plugin, `isAvailable` will be false and session will always be null.
+ *
+ * @example
+ * ```tsx
+ * import { useNativeSession } from '@clerk/expo';
+ *
+ * function MyComponent() {
+ * const { isAvailable, isLoading, isSignedIn, user } = useNativeSession();
+ *
+ * if (!isAvailable) {
+ * // Native plugin not installed, use regular useAuth() instead
+ * return ;
+ * }
+ *
+ * if (isLoading) {
+ * return ;
+ * }
+ *
+ * if (isSignedIn) {
+ * return Welcome {user?.firstName}!;
+ * }
+ *
+ * return ;
+ * }
+ * ```
+ */
+export function useNativeSession(): UseNativeSessionReturn {
+ const [isLoading, setIsLoading] = useState(isNativeSupported && !!ClerkExpo);
+ const [session, setSession] = useState(null);
+ const [user, setUser] = useState(null);
+
+ const refresh = useCallback(async () => {
+ if (!isNativeSupported || !ClerkExpo?.getSession) {
+ setIsLoading(false);
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+ const result = await ClerkExpo.getSession();
+ setSession(result?.session ?? null);
+ setUser(result?.user ?? null);
+ } catch (error) {
+ console.log('[useNativeSession] Error fetching native session:', error);
+ setSession(null);
+ setUser(null);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ // Check native session on mount
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return {
+ isAvailable: isNativeSupported && !!ClerkExpo,
+ isLoading,
+ isSignedIn: !!session?.id,
+ session,
+ user,
+ refresh,
+ };
+}
diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx
new file mode 100644
index 00000000000..16cafb34564
--- /dev/null
+++ b/packages/expo/src/native/AuthView.tsx
@@ -0,0 +1,306 @@
+import { useAuth } from '@clerk/react';
+import { Platform, requireNativeModule } from 'expo-modules-core';
+import * as SecureStore from 'expo-secure-store';
+import { useEffect, useRef } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+import { getClerkInstance } from '../provider/singleton';
+import type { AuthViewProps } from './AuthView.types';
+
+// Token cache key used by the Clerk JS SDK (must match createClerkInstance.ts)
+const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt';
+
+// Type definition for the ClerkExpo native module
+interface ClerkExpoModule {
+ configure(config: { publishableKey: string }): Promise;
+ presentAuth(options: { mode: string; dismissable: boolean }): Promise<{ sessionId?: string }>;
+ presentUserProfile(): Promise;
+ getSession(): Promise<{ sessionId?: string; user?: Record } | null>;
+ getClientToken(): Promise;
+ signOut(): Promise;
+}
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Get the native module for modal presentation
+// Wrapped in try/catch because the module may not be available if the plugin isn't configured
+let ClerkExpo: ClerkExpoModule | null = null;
+if (isNativeSupported) {
+ try {
+ ClerkExpo = requireNativeModule('ClerkExpo') as ClerkExpoModule;
+ } catch {
+ // Native module not available - plugin not configured
+ // This is expected when users use @clerk/expo without the native plugin
+ ClerkExpo = null;
+ }
+}
+
+/**
+ * A pre-built native authentication component that handles sign-in and sign-up flows.
+ *
+ * `AuthView` presents a comprehensive, native UI for authentication powered by:
+ * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios
+ * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android
+ *
+ * ## Supported Authentication Methods
+ *
+ * AuthView automatically presents the authentication methods enabled in your
+ * [Clerk Dashboard](https://dashboard.clerk.com):
+ *
+ * - **Email/Password** - Traditional email and password sign-in/sign-up
+ * - **Phone Number** - SMS-based authentication
+ * - **Username/Password** - Username-based authentication
+ * - **OAuth Providers** - Google, Apple, GitHub, Microsoft, and 20+ providers
+ * - **Passkeys** - WebAuthn/FIDO2 biometric authentication
+ * - **Multi-factor Authentication** - SMS, TOTP (authenticator apps), backup codes
+ * - **Magic Links** - Passwordless email authentication
+ * - **Enterprise SSO** - SAML and OIDC providers
+ *
+ * ## Session Synchronization
+ *
+ * When authentication completes successfully, `AuthView` automatically syncs
+ * the native session with the JavaScript SDK. After `onSuccess` is called,
+ * all `@clerk/expo` hooks will reflect the authenticated state:
+ *
+ * - `useUser()` - Returns the authenticated user
+ * - `useAuth()` - Returns `isSignedIn: true` and session tokens
+ * - `useOrganization()` - Returns active organization (if applicable)
+ * - `useSession()` - Returns the active session
+ *
+ * ## Usage with JS SDK APIs
+ *
+ * After native authentication, you have full access to all JS SDK APIs:
+ *
+ * ```tsx
+ * import { AuthView } from '@clerk/expo/native';
+ * import { useUser } from '@clerk/expo';
+ *
+ * function App() {
+ * const { user } = useUser();
+ *
+ * if (!user) {
+ * return console.log('Authenticated!')} />;
+ * }
+ *
+ * // Full JS SDK APIs available after auth:
+ * const updateName = () => user.update({ firstName: 'New Name' });
+ * const addEmail = () => user.createEmailAddress({ email: 'new@example.com' });
+ * const setupTOTP = () => user.createTOTP();
+ *
+ * return ;
+ * }
+ * ```
+ *
+ * @example Basic usage
+ * ```tsx
+ * import { AuthView } from '@clerk/expo/native';
+ *
+ * export default function SignInScreen() {
+ * return (
+ * router.replace('/home')}
+ * onError={(error) => console.error(error)}
+ * />
+ * );
+ * }
+ * ```
+ *
+ * @example Sign-up only mode
+ * ```tsx
+ * router.replace('/onboarding')}
+ * />
+ * ```
+ *
+ * @example Non-dismissable (required auth)
+ * ```tsx
+ * router.replace('/dashboard')}
+ * />
+ * ```
+ *
+ * @see {@link https://clerk.com/docs/components/authentication/sign-in} Clerk Sign-In Documentation
+ * @see {@link https://clerk.com/docs/authentication/configuration/sign-up-sign-in-options} Authentication Options
+ */
+
+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 (nativeClientToken) {
+ await SecureStore.setItemAsync(CLERK_CLIENT_JWT_KEY, nativeClientToken, {
+ keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK,
+ });
+ }
+ }
+
+ const clerkInstance = getClerkInstance();
+ if (!clerkInstance) {
+ throw new Error('[AuthView] 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') {
+ await (clerkRecord.__internal_reloadInitialResources as () => Promise)();
+ }
+
+ if (typeof clerkInstance.setActive === 'function') {
+ await clerkInstance.setActive({ session: sessionId });
+ }
+}
+
+/**
+ * Check if an error indicates the user is already signed in.
+ * Prefers structured error code, falls back to message matching.
+ */
+function isAlreadySignedInError(error: Error & { code?: string }): boolean {
+ if (error.code === 'already_signed_in') {
+ return true;
+ }
+ return /already signed in/i.test(error.message ?? '');
+}
+
+export function AuthView({ mode = 'signInOrUp', isDismissable = true, onSuccess, onError }: AuthViewProps) {
+ const { isSignedIn } = useAuth();
+ // Track if we've already completed auth to prevent duplicate onSuccess calls
+ const authCompletedRef = useRef(false);
+ // Track the initial signed-in state to differentiate between "already signed in" vs "just signed in"
+ const initialSignedInRef = useRef(isSignedIn);
+ // Track if we've started presenting
+ const hasStartedRef = useRef(false);
+
+ // Stable refs for callbacks to avoid re-triggering the effect
+ const onSuccessRef = useRef(onSuccess);
+ onSuccessRef.current = onSuccess;
+ const onErrorRef = useRef(onError);
+ onErrorRef.current = onError;
+
+ useEffect(() => {
+ if (!isNativeSupported || !ClerkExpo?.presentAuth) {
+ return;
+ }
+
+ // If auth already completed in this component instance, don't do anything
+ if (authCompletedRef.current) {
+ return;
+ }
+
+ // If we've already started presenting, don't start again
+ if (hasStartedRef.current) {
+ return;
+ }
+
+ // If user was already signed in when component mounted, call onSuccess once
+ if (initialSignedInRef.current && isSignedIn) {
+ authCompletedRef.current = true;
+ onSuccessRef.current?.();
+ return;
+ }
+
+ // If isSignedIn became true after we started (sign-in completed), don't re-present modal
+ if (isSignedIn && !initialSignedInRef.current) {
+ return;
+ }
+
+ hasStartedRef.current = true;
+
+ const presentModal = async () => {
+ // First check if native SDK has an existing session
+ // If so, sync to JS and call onSuccess without showing modal
+ if (ClerkExpo?.getSession) {
+ try {
+ const nativeSession = await ClerkExpo.getSession();
+ const sessionId = nativeSession?.sessionId;
+ if (sessionId) {
+ authCompletedRef.current = true;
+ await syncNativeSession(sessionId);
+ onSuccessRef.current?.();
+ return;
+ }
+ } catch (e) {
+ // Failed to check native session, continue to present modal
+ }
+ }
+
+ try {
+ const result = await ClerkExpo.presentAuth({
+ mode,
+ dismissable: isDismissable,
+ });
+
+ // Sync the native session to JS SDK
+ if (result.sessionId) {
+ try {
+ await syncNativeSession(result.sessionId);
+ authCompletedRef.current = true;
+ onSuccessRef.current?.();
+ } catch (syncError) {
+ console.error('[AuthView] Failed to sync session:', syncError);
+ onErrorRef.current?.(syncError as Error);
+ }
+ return;
+ }
+
+ authCompletedRef.current = true;
+ onSuccessRef.current?.();
+ } catch (err) {
+ const error = err as Error & { code?: string };
+
+ // Handle "User is already signed in" error - this means native SDK has session but JS SDK doesn't know
+ // This can happen when JS SDK failed to initialize (e.g., dev auth error) but native SDK has valid session
+ if (isAlreadySignedInError(error)) {
+ authCompletedRef.current = true;
+
+ // Get the session from native SDK and sync to JS
+ if (ClerkExpo?.getSession) {
+ try {
+ const nativeSession = await ClerkExpo.getSession();
+ if (nativeSession?.sessionId) {
+ await syncNativeSession(nativeSession.sessionId);
+ onSuccessRef.current?.();
+ return;
+ }
+ } catch (syncErr) {
+ console.error('[AuthView] Failed to sync native session:', syncErr);
+ }
+ }
+ }
+
+ onErrorRef.current?.(error);
+ }
+ };
+
+ presentModal();
+ }, [mode, isDismissable, isSignedIn]);
+
+ // Show a placeholder when native modules aren't available
+ if (!isNativeSupported || !ClerkExpo) {
+ return (
+
+
+ {!isNativeSupported
+ ? 'Native AuthView is only available on iOS and Android'
+ : 'Native AuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+
+
+ );
+ }
+
+ return ;
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ text: {
+ fontSize: 16,
+ color: '#666',
+ },
+});
diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts
new file mode 100644
index 00000000000..01a96b90751
--- /dev/null
+++ b/packages/expo/src/native/AuthView.types.ts
@@ -0,0 +1,86 @@
+/**
+ * Authentication mode that determines which flows are available to the user.
+ *
+ * - `'signInOrUp'` - Allows users to choose between signing in or creating a new account (default)
+ * - `'signIn'` - Restricts to sign-in flows only
+ * - `'signUp'` - Restricts to sign-up flows only
+ */
+export type AuthViewMode = 'signIn' | 'signUp' | 'signInOrUp';
+
+/**
+ * Props for the AuthView component.
+ *
+ * AuthView presents a comprehensive native authentication UI that handles
+ * sign-in and sign-up flows with support for multiple authentication methods.
+ */
+export interface AuthViewProps {
+ /**
+ * Authentication mode that determines which flows are available.
+ *
+ * - `'signInOrUp'` - Users can choose between signing in or creating an account
+ * - `'signIn'` - Only sign-in flows are available
+ * - `'signUp'` - Only sign-up flows are available
+ *
+ * @default 'signInOrUp'
+ */
+ mode?: AuthViewMode;
+
+ /**
+ * Whether the authentication view can be dismissed by the user.
+ *
+ * When `true`, a dismiss button appears in the navigation bar and the modal
+ * can be dismissed by swiping down or tapping outside (on iOS).
+ *
+ * When `false`, the user must complete authentication to close the view.
+ * Use this for flows where authentication is required to proceed.
+ *
+ * @default true
+ */
+ isDismissable?: boolean;
+
+ /**
+ * Callback fired when authentication completes successfully.
+ *
+ * This is called after:
+ * 1. The user successfully signs in or signs up
+ * 2. The native session is synced with the JavaScript SDK
+ *
+ * After this callback, all `@clerk/expo` hooks (`useUser()`, `useAuth()`,
+ * `useOrganization()`, etc.) will reflect the authenticated state.
+ *
+ * @example
+ * ```tsx
+ * {
+ * // User is now authenticated
+ * // useUser(), useAuth(), etc. will return user data
+ * router.replace('/home');
+ * }}
+ * />
+ * ```
+ */
+ onSuccess?: () => void;
+
+ /**
+ * Callback fired when an error occurs during authentication.
+ *
+ * Common errors include:
+ * - Network errors
+ * - Invalid credentials
+ * - Account locked
+ * - User cancelled (on some platforms)
+ *
+ * @param error - The error that occurred
+ *
+ * @example
+ * ```tsx
+ * {
+ * console.error('Auth failed:', error.message);
+ * Alert.alert('Error', error.message);
+ * }}
+ * />
+ * ```
+ */
+ onError?: (error: Error) => void;
+}
diff --git a/packages/expo/src/native/InlineAuthView.tsx b/packages/expo/src/native/InlineAuthView.tsx
new file mode 100644
index 00000000000..f15eda2da84
--- /dev/null
+++ b/packages/expo/src/native/InlineAuthView.tsx
@@ -0,0 +1,215 @@
+import { requireNativeModule, requireNativeViewManager, Platform } from 'expo-modules-core';
+import * as SecureStore from 'expo-secure-store';
+import { useCallback, useEffect, useRef } from 'react';
+import { StyleSheet, Text, View, type ViewStyle, type StyleProp } from 'react-native';
+
+import { getClerkInstance } from '../provider/singleton';
+import type { AuthViewMode } from './AuthView.types';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Token cache key used by the Clerk JS SDK (must match createClerkInstance.ts)
+const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt';
+
+// Get the native view component for inline rendering
+let NativeAuthView: any = null;
+let ClerkExpoModule: {
+ getSession(): Promise<{ sessionId?: string } | null>;
+ getClientToken(): Promise;
+} | null = null;
+if (isNativeSupported) {
+ try {
+ NativeAuthView = requireNativeViewManager('ClerkExpo', 'ClerkAuthExpoView');
+ } catch {
+ NativeAuthView = null;
+ }
+ try {
+ ClerkExpoModule = requireNativeModule('ClerkExpo');
+ } catch {
+ ClerkExpoModule = null;
+ }
+}
+
+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 true
+ */
+ isDismissable?: boolean;
+
+ /**
+ * Callback fired when authentication completes successfully.
+ * After this callback, all `@clerk/expo` hooks will reflect the authenticated state.
+ */
+ onSuccess?: () => void;
+
+ /**
+ * Callback fired when an error occurs during authentication.
+ */
+ onError?: (error: Error) => void;
+
+ /**
+ * Style applied to the container view.
+ */
+ style?: StyleProp;
+}
+
+/**
+ * An inline native authentication component that renders in-place (not as a modal).
+ *
+ * Unlike `AuthView` which presents a full-screen modal, `InlineAuthView` renders
+ * directly within your React Native view hierarchy, allowing you to embed the
+ * native authentication UI anywhere in your layout.
+ *
+ * @example
+ * ```tsx
+ * import { InlineAuthView } from '@clerk/expo/native';
+ *
+ * export default function SignInScreen() {
+ * return (
+ *
+ * Welcome
+ * router.replace('/home')}
+ * />
+ *
+ * );
+ * }
+ * ```
+ */
+export function InlineAuthView({
+ mode = 'signInOrUp',
+ isDismissable = true,
+ onSuccess,
+ onError: _onError,
+ style,
+}: InlineAuthViewProps) {
+ const authCompletedRef = useRef(false);
+
+ const syncSession = useCallback(
+ async (sessionId: string) => {
+ if (authCompletedRef.current) return;
+ authCompletedRef.current = true;
+
+ console.log('[InlineAuthView] Syncing session:', sessionId);
+ try {
+ // The native SDK (clerk-ios/clerk-android) and JS SDK (clerk-js) use separate
+ // Clerk API clients. The native session won't appear in the JS client's sessions.
+ // To fix this, we copy the native client's bearer token to the JS SDK's token cache
+ // so both SDKs use the same Clerk API client.
+ if (ClerkExpoModule?.getClientToken) {
+ const nativeClientToken = await ClerkExpoModule.getClientToken();
+ if (nativeClientToken) {
+ console.log('[InlineAuthView] Got native client token, syncing to JS SDK...');
+ await SecureStore.setItemAsync(CLERK_CLIENT_JWT_KEY, nativeClientToken, {
+ keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK,
+ });
+ }
+ }
+
+ // Get the raw Clerk instance (not IsomorphicClerk from useClerk())
+ // because __internal_reloadInitialResources is stripped from IsomorphicClerk
+ const clerkInstance = getClerkInstance()!;
+ const clerkAny = clerkInstance as any;
+
+ // Reload the client from the API - now using the native client's token,
+ // so the JS SDK will see the same sessions as the native SDK
+ if (typeof clerkAny.__internal_reloadInitialResources === 'function') {
+ await clerkAny.__internal_reloadInitialResources();
+ console.log('[InlineAuthView] Resources reloaded with native client token');
+ }
+
+ if (clerkInstance?.setActive) {
+ await clerkInstance.setActive({ session: sessionId });
+ console.log('[InlineAuthView] Session synced successfully');
+ }
+ } catch (err) {
+ console.error('[InlineAuthView] Failed to sync session:', err);
+ }
+
+ onSuccess?.();
+ },
+ [onSuccess],
+ );
+
+ // Handle native events from the ExpoView bridge
+ const handleAuthEvent = useCallback(
+ async (event: { nativeEvent: { type: string; data: Record } }) => {
+ const { type, data } = event.nativeEvent;
+ console.log('[InlineAuthView] Native event:', type, data);
+
+ if (type === 'signInCompleted' || type === 'signUpCompleted') {
+ const sessionId = data?.sessionId;
+ if (sessionId) {
+ await syncSession(sessionId);
+ }
+ }
+ },
+ [syncSession],
+ );
+
+ // Fallback: poll native session to detect auth completion
+ // This handles cases where the native event bridge doesn't fire
+ useEffect(() => {
+ if (!ClerkExpoModule?.getSession) return;
+
+ const interval = setInterval(async () => {
+ if (authCompletedRef.current) {
+ clearInterval(interval);
+ return;
+ }
+
+ try {
+ const session = await ClerkExpoModule!.getSession();
+ if (session?.sessionId) {
+ console.log('[InlineAuthView] Poll detected session:', session.sessionId);
+ clearInterval(interval);
+ await syncSession(session.sessionId);
+ }
+ } catch {
+ // ignore polling errors
+ }
+ }, 1500);
+
+ return () => clearInterval(interval);
+ }, [syncSession]);
+
+ if (!isNativeSupported || !NativeAuthView) {
+ 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,
+ },
+ text: {
+ fontSize: 16,
+ color: '#666',
+ },
+});
diff --git a/packages/expo/src/native/InlineUserProfileView.tsx b/packages/expo/src/native/InlineUserProfileView.tsx
new file mode 100644
index 00000000000..d66d4d8068a
--- /dev/null
+++ b/packages/expo/src/native/InlineUserProfileView.tsx
@@ -0,0 +1,143 @@
+import { useClerk } from '@clerk/react';
+import { requireNativeViewManager, Platform } from 'expo-modules-core';
+import { useCallback, useRef } from 'react';
+import { StyleSheet, Text, View, type ViewStyle, type StyleProp } from 'react-native';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Get the native view component for inline rendering
+let NativeUserProfileView: any = null;
+if (isNativeSupported) {
+ try {
+ NativeUserProfileView = requireNativeViewManager('ClerkExpo', 'ClerkUserProfileExpoView');
+ } catch {
+ NativeUserProfileView = null;
+ }
+}
+
+// Get the native module for session operations
+let ClerkExpo: { signOut(): Promise; getSession(): Promise } | null = null;
+if (isNativeSupported) {
+ try {
+ const { requireNativeModule } = require('expo-modules-core');
+ ClerkExpo = requireNativeModule('ClerkExpo');
+ } catch {
+ ClerkExpo = null;
+ }
+}
+
+export interface InlineUserProfileViewProps {
+ /**
+ * Whether the profile view can be dismissed by the user.
+ * @default true
+ */
+ isDismissable?: boolean;
+
+ /**
+ * Callback fired when the user signs out from the profile view.
+ * After this callback, `useAuth()` will return `isSignedIn: false`.
+ */
+ onSignOut?: () => void;
+
+ /**
+ * Callback fired when the user dismisses the profile view.
+ */
+ onDismiss?: () => void;
+
+ /**
+ * Style applied to the container view.
+ */
+ style?: StyleProp;
+}
+
+/**
+ * An inline native user profile component that renders in-place (not as a modal).
+ *
+ * Unlike `UserProfileView` which presents a full-screen modal, `InlineUserProfileView`
+ * renders directly within your React Native view hierarchy.
+ *
+ * @example
+ * ```tsx
+ * import { InlineUserProfileView } from '@clerk/expo/native';
+ *
+ * export default function ProfileScreen() {
+ * return (
+ * router.replace('/sign-in')}
+ * />
+ * );
+ * }
+ * ```
+ */
+export function InlineUserProfileView({
+ isDismissable = true,
+ onSignOut,
+ onDismiss,
+ style,
+}: InlineUserProfileViewProps) {
+ const clerk = useClerk();
+ const signOutTriggered = useRef(false);
+
+ const handleProfileEvent = useCallback(
+ async (event: { nativeEvent: { type: string; data: Record } }) => {
+ const { type } = event.nativeEvent;
+
+ if (type === 'signedOut' && !signOutTriggered.current) {
+ signOutTriggered.current = true;
+
+ // Clear native session
+ try {
+ await ClerkExpo?.signOut();
+ } catch {
+ // May already be signed out
+ }
+
+ // Sign out from JS SDK
+ if (clerk?.signOut) {
+ try {
+ await clerk.signOut();
+ } catch (err) {
+ console.warn('[InlineUserProfileView] JS SDK sign out error:', err);
+ }
+ }
+
+ onSignOut?.();
+ } else if (type === 'dismissed') {
+ onDismiss?.();
+ }
+ },
+ [clerk, onSignOut, onDismiss],
+ );
+
+ if (!isNativeSupported || !NativeUserProfileView) {
+ 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/README.md b/packages/expo/src/native/README.md
new file mode 100644
index 00000000000..f602a73993e
--- /dev/null
+++ b/packages/expo/src/native/README.md
@@ -0,0 +1,246 @@
+# Clerk Native iOS Components
+
+This package provides **complete 1:1 access to all 107 SwiftUI components** from the official [clerk-ios SDK](https://github.com/clerk/clerk-ios) through 3 high-level components.
+
+## Architecture
+
+The clerk-ios SDK is architected with 3 public-facing views that internally compose 104+ sub-components:
+
+### 1. AuthView (SignIn Component)
+
+**Wraps 35+ internal authentication screens including:**
+
+- Sign-in flows (email, phone, username, OAuth providers)
+- Sign-up flows with verification
+- Multi-factor authentication (SMS, TOTP, backup codes)
+- Password reset and account recovery
+- Passkey authentication
+- Alternative authentication methods
+- Forgot password flows
+- Get help screens
+
+**Internal Components (automatically included):**
+
+- `AuthStartView`
+- `SignInFactorOneView`
+- `SignInFactorOnePasswordView`
+- `SignInFactorOnePasskeyView`
+- `SignInFactorCodeView`
+- `SignInFactorTwoView`
+- `SignInFactorTwoBackupCodeView`
+- `SignInFactorAlternativeMethodsView`
+- `SignInForgotPasswordView`
+- `SignInSetNewPasswordView`
+- `SignInGetHelpView`
+- `SignUpCodeView`
+- `SignUpCollectFieldView`
+- `SignUpCompleteProfileView`
+- Plus 20+ common UI components
+
+### 2. UserButton
+
+**Wraps 4+ internal components including:**
+
+- User avatar display
+- User profile popover
+- Account switcher (multi-session support)
+- Quick sign-out
+
+**Internal Components (automatically included):**
+
+- `UserButtonPopover`
+- `UserButtonAccountSwitcher`
+- `UserPreviewView`
+- `UserProfileRowView`
+
+### 3. UserProfileView
+
+**Wraps 65+ internal profile management screens including:**
+
+- Profile information display and editing
+- Email address management (add, verify, remove, set primary)
+- Phone number management (add, verify, remove, set primary)
+- Password management and updates
+- MFA settings (SMS, TOTP authenticator apps, backup codes)
+- Passkey management (add, rename, remove)
+- Connected OAuth accounts management
+- Active device sessions management
+- Account switching (multi-session mode)
+- Delete account
+- Sign out
+
+**Internal Components (automatically included):**
+
+- `UserProfileDetailView`
+- `UserProfileUpdateProfileView`
+- `UserProfileSecurityView`
+- `UserProfileAddEmailView`
+- `UserProfileEmailRow`
+- `UserProfileAddPhoneView`
+- `UserProfilePhoneRow`
+- `UserProfilePasswordSection`
+- `UserProfileChangePasswordView`
+- `UserProfileMfaSection`
+- `UserProfileMfaRow`
+- `UserProfileMfaAddSmsView`
+- `UserProfileMfaAddTotpView`
+- `UserProfileAddMfaView`
+- `BackupCodesView`
+- `UserProfilePasskeySection`
+- `UserProfilePasskeyRow`
+- `UserProfilePasskeyRenameView`
+- `UserProfileExternalAccountRow`
+- `UserProfileAddConnectedAccountView`
+- `UserProfileDevicesSection`
+- `UserProfileDeviceRow`
+- `UserProfileButtonRow`
+- `UserProfileDeleteAccountSection`
+- `UserProfileDeleteAccountConfirmationView`
+- `UserProfileSectionHeader`
+- `UserProfileVerifyView`
+- Plus 40+ common UI components
+
+### Common UI Components (19+ files)
+
+All 3 public components share these internal building blocks:
+
+- `ClerkTextField`
+- `ClerkPhoneNumberField`
+- `OTPField`
+- `AsyncButton`
+- `SocialButton`
+- `SocialButtonLayout`
+- `ErrorView`
+- `ErrorText`
+- `HeaderView`
+- `DismissButton`
+- `AppLogoView`
+- `Badge`
+- `ClerkFocusedBorder`
+- `IdentityPreviewView`
+- `OverlayProgressView`
+- `SecuredByClerkView`
+- `SpinnerView`
+- `TextDivider`
+- `WrappingHStack`
+
+### Theme System (10+ files)
+
+- `ClerkTheme`
+- `ClerkColors`
+- `ClerkFonts`
+- `ClerkDesign`
+- `ClerkThemes`
+- `PrimaryButtonStyle`
+- `SecondaryButtonStyle`
+- `NegativeButtonStyle`
+- `PressedBackgroundButtonStyle`
+- `ClerkButtonConfig`
+
+## What This Means
+
+When you import and use these 3 components, you get **full access to ALL 107 files** and every single screen, flow, and feature from clerk-ios:
+
+```typescript
+import { AuthView, UserButton, UserProfileView } from '@clerk/expo/native'
+
+// This ONE component gives you access to:
+// - 15+ sign-in screens
+// - 10+ sign-up screens
+// - 10+ MFA screens
+// - 5+ password reset screens
+// - 50+ internal UI components
+
+
+// This ONE component gives you access to:
+// - User avatar
+// - Profile popover
+// - Account switcher
+// - 4+ internal components
+
+
+// This ONE component gives you access to:
+// - 25+ profile management screens
+// - 15+ security settings screens
+// - 10+ MFA configuration screens
+// - 10+ device management screens
+// - 40+ internal UI components
+
+```
+
+## Complete Feature List
+
+Every single feature from clerk-ios is now available in React Native:
+
+### Authentication Features
+
+✅ Email + Password sign-in
+✅ Phone number sign-in with SMS OTP
+✅ Username sign-in
+✅ Email sign-up with verification
+✅ Phone sign-up with SMS verification
+✅ OAuth providers (Google, Apple, GitHub, etc.)
+✅ Passkey authentication (WebAuthn)
+✅ Multi-factor authentication (MFA)
+✅ SMS-based 2FA
+✅ TOTP authenticator apps (Google Authenticator, Authy, etc.)
+✅ Backup codes
+✅ Password reset flows
+✅ Forgot password
+✅ Account recovery
+✅ Alternative authentication methods
+
+### Profile Management Features
+
+✅ View and edit profile information
+✅ Update name, username
+✅ Manage profile image
+✅ Add/remove email addresses
+✅ Verify email addresses
+✅ Set primary email
+✅ Add/remove phone numbers
+✅ Verify phone numbers
+✅ Set primary phone
+✅ Change password
+✅ Password strength validation
+✅ Enable/disable MFA
+✅ Configure SMS 2FA
+✅ Configure TOTP 2FA
+✅ Generate backup codes
+✅ View/download backup codes
+✅ Add passkeys
+✅ Rename passkeys
+✅ Remove passkeys
+✅ Connect OAuth accounts
+✅ Disconnect OAuth accounts
+✅ View active sessions
+✅ View devices
+✅ Revoke device sessions
+✅ Sign out from specific devices
+✅ Multi-session support
+✅ Account switching
+✅ Add accounts
+✅ Delete account
+
+### UI/UX Features
+
+✅ Clerk's official design system
+✅ Light/dark theme support
+✅ Customizable themes
+✅ Responsive layouts
+✅ Native iOS look and feel
+✅ Smooth animations
+✅ Loading states
+✅ Error handling
+✅ Form validation
+✅ Accessibility support
+
+## Total Component Count
+
+- **3 Public Components** (exported from this package)
+- **104 Internal Components** (automatically included)
+- **107 Total Components** from clerk-ios
+
+## Usage Examples
+
+See the `/examples` directory for comprehensive usage examples of all features.
diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx
new file mode 100644
index 00000000000..3bcd95ec596
--- /dev/null
+++ b/packages/expo/src/native/UserButton.tsx
@@ -0,0 +1,313 @@
+import { useClerk, useUser } from '@clerk/react';
+import { Platform } from 'expo-modules-core';
+import { useEffect, useState } from 'react';
+import { TouchableOpacity, View, Text, StyleSheet, StyleProp, ViewStyle, Image } from 'react-native';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Get the native module for modal presentation (use optional require to avoid crash if not available)
+let ClerkExpo: {
+ getSession: () => Promise<{
+ session?: { id: string };
+ user?: { id: string; firstName?: string; lastName?: string; imageUrl?: string; primaryEmailAddress?: string };
+ } | null>;
+ presentUserProfile: (options: { dismissable: boolean }) => Promise<{ session?: { id: string } } | null>;
+ signOut: () => Promise;
+} | null = null;
+if (isNativeSupported) {
+ try {
+ const { requireNativeModule } = require('expo-modules-core');
+ ClerkExpo = requireNativeModule('ClerkExpo');
+ } catch {
+ console.log('[UserButton] ClerkExpo native module not available on this platform');
+ }
+}
+
+interface NativeUser {
+ id: string;
+ firstName?: string;
+ lastName?: string;
+ imageUrl?: string;
+ primaryEmailAddress?: string;
+}
+
+/**
+ * Props for the UserButton component.
+ */
+export interface UserButtonProps {
+ /**
+ * Custom style for the button container.
+ */
+ style?: StyleProp;
+
+ /**
+ * Callback fired when the user button is pressed.
+ *
+ * This is called immediately when the button is tapped, before the
+ * profile modal is presented. Use this for analytics or custom behavior.
+ */
+ onPress?: () => void;
+
+ /**
+ * Callback fired when the user signs out from the profile modal.
+ *
+ * This is called after:
+ * 1. The native session is cleared
+ * 2. The JS SDK session is cleared
+ *
+ * After this callback, `useAuth()` will return `isSignedIn: false`.
+ */
+ onSignOut?: () => void;
+}
+
+/**
+ * A pre-built native button component that displays the user's avatar and opens their profile.
+ *
+ * `UserButton` renders a circular button showing the user's profile image (or initials if
+ * no image is available). When tapped, it presents the {@link UserProfileView} modal for
+ * account management.
+ *
+ * This component is powered by:
+ * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios
+ * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android
+ *
+ * ## Features
+ *
+ * - **Profile Image**: Displays the user's profile photo from their Clerk account
+ * - **Initials Fallback**: Shows user's initials when no profile image is set
+ * - **Profile Modal**: Opens {@link UserProfileView} with full account management
+ * - **Sign Out Handling**: Properly syncs sign-out between native and JS SDKs
+ *
+ * ## Avatar Display
+ *
+ * The button displays the user's avatar in this order of preference:
+ * 1. User's profile image from Clerk (if available)
+ * 2. First letter of first name + first letter of last name
+ * 3. "U" as a fallback
+ *
+ * ## Styling
+ *
+ * The button is 36x36 pixels by default with circular border radius.
+ * You can customize the size using the `style` prop:
+ *
+ * ```tsx
+ *
+ * ```
+ *
+ * @example Basic usage in a header
+ * ```tsx
+ * import { UserButton } from '@clerk/expo/native';
+ *
+ * export default function Header() {
+ * return (
+ *
+ * My App
+ *
+ *
+ * );
+ * }
+ * ```
+ *
+ * @example With sign-out handling
+ * ```tsx
+ * router.replace('/sign-in')}
+ * style={{ width: 40, height: 40 }}
+ * />
+ * ```
+ *
+ * @example With press tracking
+ * ```tsx
+ * analytics.track('profile_opened')}
+ * onSignOut={() => {
+ * analytics.track('signed_out');
+ * router.replace('/sign-in');
+ * }}
+ * />
+ * ```
+ *
+ * @see {@link UserProfileView} The profile view that opens when tapped
+ * @see {@link https://clerk.com/docs/components/user/user-button} Clerk UserButton Documentation
+ */
+export function UserButton({ onPress, onSignOut, style }: UserButtonProps) {
+ const [nativeUser, setNativeUser] = useState(null);
+ const clerk = useClerk();
+ // Use the reactive user hook from clerk-react to observe sign-out state changes
+ const { user: clerkUser, isSignedIn } = useUser();
+
+ // Fetch native user data on mount and when clerk user changes
+ useEffect(() => {
+ const fetchUser = async () => {
+ if (!isNativeSupported || !ClerkExpo?.getSession) {
+ return;
+ }
+
+ try {
+ const session = await ClerkExpo.getSession();
+ if (session?.user) {
+ setNativeUser(session.user);
+ } else {
+ // Clear local state if no native session
+ setNativeUser(null);
+ }
+ } catch (err) {
+ console.error('[UserButton] Error fetching user:', err);
+ }
+ };
+
+ 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 () => {
+ onPress?.();
+
+ if (!isNativeSupported || !ClerkExpo?.presentUserProfile) {
+ return;
+ }
+
+ try {
+ console.log('[UserButton] Presenting native profile modal');
+ const result = await ClerkExpo.presentUserProfile({
+ dismissable: true,
+ });
+
+ console.log('[UserButton] Profile modal closed, result:', result);
+
+ // Check if native session still exists after modal closes
+ // If session is null, user signed out from the native UI
+ const sessionCheck = await ClerkExpo.getSession?.();
+ const hasNativeSession = !!sessionCheck?.session;
+
+ console.log('[UserButton] Native session after close:', hasNativeSession);
+
+ if (!hasNativeSession) {
+ console.log('[UserButton] User signed out from native profile');
+ console.log('[UserButton] JS SDK isSignedIn:', isSignedIn);
+
+ // Clear local state immediately for instant UI feedback
+ setNativeUser(null);
+
+ // Clear native session explicitly (may already be cleared, but ensure it)
+ try {
+ console.log('[UserButton] Clearing native session...');
+ await ClerkExpo.signOut?.();
+ console.log('[UserButton] Native session cleared');
+ } catch (nativeSignOutErr) {
+ console.warn('[UserButton] Native sign out error (may already be signed out):', nativeSignOutErr);
+ }
+
+ // Sign out from JS SDK - this is critical to update isSignedIn state
+ // This will trigger useUser() to update, causing parent components to re-render
+ if (clerk?.signOut) {
+ try {
+ console.log('[UserButton] Signing out from JS SDK...');
+ await clerk.signOut();
+ console.log('[UserButton] JS SDK signed out successfully');
+ } catch (signOutErr) {
+ console.warn('[UserButton] JS SDK sign out error:', signOutErr);
+ // Even if signOut throws, try to force reload to clear stale state
+ if ((clerk as any)?.__internal_reloadInitialResources) {
+ try {
+ console.log('[UserButton] Force reloading JS SDK state...');
+ await (clerk as any).__internal_reloadInitialResources();
+ console.log('[UserButton] JS SDK state reloaded');
+ } catch (reloadErr) {
+ console.warn('[UserButton] Failed to reload JS SDK state:', reloadErr);
+ }
+ }
+ }
+ }
+
+ // Call the onSignOut callback AFTER JS SDK sign-out completes
+ console.log('[UserButton] Calling onSignOut callback');
+ onSignOut?.();
+ } else {
+ console.log('[UserButton] User dismissed profile without signing out');
+ // User just closed the profile, don't trigger sign-out
+ }
+ } catch (err) {
+ console.error('[UserButton] Error presenting profile:', err);
+ }
+ };
+
+ // Get initials from user name
+ const getInitials = () => {
+ if (user?.firstName) {
+ const first = user.firstName.charAt(0).toUpperCase();
+ const last = user.lastName?.charAt(0).toUpperCase() || '';
+ return first + last;
+ }
+ return 'U';
+ };
+
+ // Show fallback when native modules aren't available
+ if (!isNativeSupported || !ClerkExpo) {
+ return (
+
+ ?
+
+ );
+ }
+
+ return (
+
+ {user?.imageUrl ? (
+
+ ) : (
+
+ {getInitials()}
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ button: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ overflow: 'hidden',
+ },
+ avatar: {
+ flex: 1,
+ backgroundColor: '#6366f1',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ avatarImage: {
+ width: '100%',
+ height: '100%',
+ borderRadius: 18,
+ },
+ avatarText: {
+ color: 'white',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ text: {
+ fontSize: 14,
+ color: '#666',
+ },
+});
diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo/src/native/UserProfileView.tsx
new file mode 100644
index 00000000000..9de39aea254
--- /dev/null
+++ b/packages/expo/src/native/UserProfileView.tsx
@@ -0,0 +1,265 @@
+import { useClerk, useAuth } from '@clerk/react';
+import { Platform } from 'expo-modules-core';
+import { useEffect, useRef } from 'react';
+import { View, Text, StyleSheet, ViewProps } from 'react-native';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Get the native module for modal presentation (use optional require to avoid crash if not available)
+let ClerkExpo: {
+ presentUserProfile: (options: { dismissable: boolean }) => Promise<{ session?: { id: string } } | null>;
+ signOut: () => Promise;
+ getSession: () => Promise<{ session?: { id: string } } | null>;
+} | null = null;
+if (isNativeSupported) {
+ try {
+ const { requireNativeModule } = require('expo-modules-core');
+ ClerkExpo = requireNativeModule('ClerkExpo');
+ } catch {
+ console.log('[UserProfileView] ClerkExpo native module not available on this platform');
+ }
+}
+
+/**
+ * Props for the UserProfileView component.
+ */
+export interface UserProfileViewProps extends ViewProps {
+ /**
+ * Whether the profile view can be dismissed by the user.
+ *
+ * When `true`, a dismiss button appears in the navigation bar and the modal
+ * can be dismissed by swiping down or tapping outside (on iOS).
+ *
+ * When `false`, the user must use the sign-out action to close the view.
+ *
+ * @default true
+ */
+ isDismissable?: boolean;
+
+ /**
+ * Callback fired when the user signs out from the profile view.
+ *
+ * This is called after:
+ * 1. The native session is cleared
+ * 2. The JS SDK session is cleared
+ *
+ * After this callback, `useAuth()` will return `isSignedIn: false`.
+ */
+ onSignOut?: () => void;
+}
+
+/**
+ * A pre-built native component for managing the user's profile and account settings.
+ *
+ * `UserProfileView` presents a comprehensive, native UI for account management powered by:
+ * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios
+ * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android
+ *
+ * ## Profile Sections
+ *
+ * The profile view includes all user management features enabled in your
+ * [Clerk Dashboard](https://dashboard.clerk.com):
+ *
+ * ### Profile Information
+ * - View and edit profile photo
+ * - Update first name, last name, username
+ * - View account creation date
+ *
+ * ### Email Addresses
+ * - View all email addresses
+ * - Add new email addresses
+ * - Verify email addresses
+ * - Remove email addresses
+ * - Set primary email address
+ *
+ * ### Phone Numbers
+ * - View all phone numbers
+ * - Add new phone numbers
+ * - Verify phone numbers via SMS
+ * - Remove phone numbers
+ * - Set primary phone number
+ *
+ * ### Password & Security
+ * - Change password
+ * - Set password (if using passwordless auth)
+ *
+ * ### Multi-Factor Authentication
+ * - Enable/disable SMS verification
+ * - Enable/disable TOTP (authenticator apps)
+ * - View and regenerate backup codes
+ *
+ * ### Passkeys
+ * - View registered passkeys
+ * - Add new passkeys
+ * - Remove passkeys
+ *
+ * ### Connected Accounts
+ * - View connected OAuth providers (Google, Apple, GitHub, etc.)
+ * - Connect new OAuth accounts
+ * - Disconnect OAuth accounts
+ *
+ * ### Active Sessions
+ * - View all active sessions/devices
+ * - Sign out from other devices
+ * - See session details (IP, location, browser)
+ *
+ * ### Account Actions
+ * - Sign out
+ * - Delete account (if enabled)
+ *
+ * ## Usage with JS SDK APIs
+ *
+ * While `UserProfileView` provides a native UI for common operations, you can
+ * also use JS SDK APIs directly for custom implementations:
+ *
+ * ```tsx
+ * import { useUser } from '@clerk/expo';
+ *
+ * function CustomProfile() {
+ * const { user } = useUser();
+ *
+ * // Profile updates
+ * await user.update({ firstName: 'New Name' });
+ *
+ * // Email management
+ * await user.createEmailAddress({ email: 'new@example.com' });
+ * await emailAddress.prepareVerification({ strategy: 'email_code' });
+ * await emailAddress.attemptVerification({ code: '123456' });
+ *
+ * // MFA setup
+ * const totp = await user.createTOTP();
+ * await totp.attemptVerification({ code: '123456' });
+ *
+ * // Passkey management
+ * await user.createPasskey();
+ * }
+ * ```
+ *
+ * @example Basic usage
+ * ```tsx
+ * import { UserProfileView } from '@clerk/expo/native';
+ *
+ * export default function ProfileScreen() {
+ * return (
+ * router.replace('/sign-in')}
+ * />
+ * );
+ * }
+ * ```
+ *
+ * @example Non-dismissable profile
+ * ```tsx
+ * router.replace('/sign-in')}
+ * style={{ flex: 1 }}
+ * />
+ * ```
+ *
+ * @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation
+ * @see {@link https://clerk.com/docs/users/overview} User Management Documentation
+ */
+export function UserProfileView({ isDismissable = true, onSignOut, style, ...props }: UserProfileViewProps) {
+ const clerk = useClerk();
+ const { isSignedIn } = useAuth();
+ // Track if we've already triggered sign-out to prevent double-calls
+ const signOutTriggered = useRef(false);
+
+ // Stable refs for callbacks/values to avoid re-triggering the effect
+ const onSignOutRef = useRef(onSignOut);
+ onSignOutRef.current = onSignOut;
+ const clerkRef = useRef(clerk);
+ clerkRef.current = clerk;
+ const isSignedInRef = useRef(isSignedIn);
+ isSignedInRef.current = isSignedIn;
+
+ // Reset sign-out flag on mount only
+ useEffect(() => {
+ signOutTriggered.current = false;
+ }, []);
+
+ useEffect(() => {
+ if (!isNativeSupported || !ClerkExpo?.presentUserProfile) {
+ return;
+ }
+
+ const presentModal = async () => {
+ try {
+ await ClerkExpo.presentUserProfile({
+ dismissable: isDismissable,
+ });
+
+ // Check if native session still exists after modal closes
+ // If session is null, user signed out from the native UI
+ const sessionCheck = await ClerkExpo.getSession?.();
+ const hasNativeSession = !!sessionCheck?.session;
+
+ if (!hasNativeSession && !signOutTriggered.current) {
+ signOutTriggered.current = true;
+
+ // Clear native session explicitly (may already be cleared, but ensure it)
+ try {
+ await ClerkExpo.signOut?.();
+ } catch {
+ // May already be signed out
+ }
+
+ // Sign out from JS SDK - this is critical to update isSignedIn state
+ const currentClerk = clerkRef.current;
+ if (currentClerk?.signOut) {
+ try {
+ await currentClerk.signOut();
+ } catch (signOutErr) {
+ console.warn('[UserProfileView] JS SDK sign out error:', signOutErr);
+ // TODO: Consider a public API for force-refreshing SDK state
+ }
+ }
+
+ // Call onSignOut callback AFTER JS SDK sign-out completes
+ onSignOutRef.current?.();
+ }
+ } catch (err) {
+ console.error('[UserProfileView] Error:', err);
+ }
+ };
+
+ void presentModal();
+ }, [isDismissable]);
+
+ // Show a placeholder when native modules aren't available
+ if (!isNativeSupported || !ClerkExpo) {
+ return (
+
+
+ {!isNativeSupported
+ ? 'Native UserProfileView is only available on iOS and Android'
+ : 'Native UserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ text: {
+ fontSize: 16,
+ color: '#666',
+ },
+});
diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts
new file mode 100644
index 00000000000..87531dae19b
--- /dev/null
+++ b/packages/expo/src/native/index.ts
@@ -0,0 +1,61 @@
+/**
+ * Native UI components for Clerk authentication in Expo apps.
+ *
+ * These components provide pre-built, native authentication experiences powered by:
+ * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios
+ * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android
+ *
+ * ## Installation
+ *
+ * Native components require the `@clerk/expo` plugin to be configured in your `app.json`:
+ *
+ * ```json
+ * {
+ * "expo": {
+ * "plugins": ["@clerk/expo"]
+ * }
+ * }
+ * ```
+ *
+ * Then run `npx expo prebuild` to generate native code.
+ *
+ * ## Components
+ *
+ * - {@link AuthView} - Authentication flow (sign-in/sign-up)
+ * - {@link UserProfileView} - User profile and account management
+ * - {@link UserButton} - Avatar button that opens profile
+ *
+ * ## Usage with JS SDK
+ *
+ * After authenticating with native components, all `@clerk/expo` hooks work normally:
+ *
+ * ```tsx
+ * import { AuthView } from '@clerk/expo/native';
+ * import { useUser, useOrganization } from '@clerk/expo';
+ *
+ * function App() {
+ * const { user } = useUser();
+ * const { organization } = useOrganization();
+ *
+ * if (!user) {
+ * return console.log('Ready!')} />;
+ * }
+ *
+ * // All JS SDK APIs available after native auth
+ * return ;
+ * }
+ * ```
+ *
+ * @module @clerk/expo/native
+ */
+
+export { AuthView } from './AuthView';
+export type { AuthViewProps, AuthViewMode } from './AuthView.types';
+export { InlineAuthView } from './InlineAuthView';
+export type { InlineAuthViewProps } from './InlineAuthView';
+export { InlineUserProfileView } from './InlineUserProfileView';
+export type { InlineUserProfileViewProps } from './InlineUserProfileView';
+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 d342ef370b4..3f445bd5bc7 100644
--- a/packages/expo/src/plugin/withClerkExpo.ts
+++ b/packages/expo/src/plugin/withClerkExpo.ts
@@ -1,11 +1,103 @@
-import { type ConfigPlugin, createRunOncePlugin, withInfoPlist } from '@expo/config-plugins';
+import {
+ type ConfigPlugin,
+ createRunOncePlugin,
+ withInfoPlist,
+ withAppBuildGradle,
+ withDangerousMod,
+} from '@expo/config-plugins';
+import * as fs from 'fs';
+import * as path from 'path';
import pkg from '../../package.json';
+/**
+ * The native module configuration that gets enabled when the plugin is used.
+ * This is written to expo-module.config.json to enable autolinking of native modules.
+ */
+const NATIVE_MODULE_CONFIG = {
+ platforms: ['android', 'ios'],
+ android: {
+ modules: ['expo.modules.clerk.ClerkExpoModule', 'expo.modules.clerk.googlesignin.ClerkGoogleSignInModule'],
+ },
+ ios: {
+ modules: ['ClerkExpoModule', 'ClerkGoogleSignInModule'],
+ },
+};
+
+/**
+ * Enable native modules by writing the full expo-module.config.json.
+ *
+ * By default, @clerk/expo ships with an empty config ({ "platforms": [] }) so that
+ * users who don't need native features can use the package without native dependencies.
+ *
+ * When the plugin is configured, this function writes the real config to enable
+ * autolinking of the native modules (ClerkExpoModule, ClerkGoogleSignInModule).
+ */
+const withClerkNativeModules: ConfigPlugin = config => {
+ return withDangerousMod(config, [
+ 'ios',
+ async modConfig => {
+ try {
+ // Find the @clerk/expo package directory
+ const packageJsonPath = require.resolve('@clerk/expo/package.json');
+ const packageDir = path.dirname(packageJsonPath);
+ const configPath = path.join(packageDir, 'expo-module.config.json');
+
+ // Write the config that enables native modules
+ fs.writeFileSync(configPath, JSON.stringify(NATIVE_MODULE_CONFIG, null, 2) + '\n');
+
+ console.log('✅ Clerk native modules enabled');
+ } catch (error) {
+ console.warn('⚠️ Could not enable Clerk native modules:', error);
+ }
+
+ return modConfig;
+ },
+ ]);
+};
+
+/**
+ * Add packaging exclusions to Android app build.gradle to resolve
+ * duplicate META-INF file conflicts from clerk-android dependencies.
+ */
+const withClerkAndroidPackaging: ConfigPlugin = config => {
+ return withAppBuildGradle(config, modConfig => {
+ let buildGradle = modConfig.modResults.contents;
+
+ // Check if exclusion already exists
+ if (buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) {
+ console.log('✅ Clerk Android packaging exclusions already configured');
+ return modConfig;
+ }
+
+ // Find the existing packagingOptions block and add resources.excludes
+ const packagingOptionsMatch = buildGradle.match(/packagingOptions\s*\{/);
+ if (packagingOptionsMatch) {
+ // Add resources block inside packagingOptions
+ const resourcesExclude = `packagingOptions {
+ // Clerk Android SDK: exclude duplicate META-INF files
+ resources {
+ excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
+ }`;
+
+ buildGradle = buildGradle.replace(/packagingOptions\s*\{/, resourcesExclude);
+ modConfig.modResults.contents = buildGradle;
+ console.log('✅ Clerk Android packaging exclusions added');
+ } else {
+ console.warn('⚠️ Could not find packagingOptions block in build.gradle');
+ }
+
+ return modConfig;
+ });
+};
+
/**
* Expo config plugin for @clerk/expo.
*
- * This plugin configures the iOS URL scheme required for Google Sign-In.
+ * This plugin configures:
+ * - iOS: URL scheme required for Google Sign-In
+ * - Android: Packaging exclusions to resolve dependency conflicts
+ *
* The native Android module is automatically linked via expo-module.config.json.
*/
const withClerkGoogleSignIn: ConfigPlugin = config => {
@@ -42,4 +134,20 @@ const withClerkGoogleSignIn: ConfigPlugin = config => {
});
};
-export default createRunOncePlugin(withClerkGoogleSignIn, pkg.name, pkg.version);
+/**
+ * Combined plugin that applies all Clerk configurations.
+ *
+ * When this plugin is used, it:
+ * 1. Enables native modules by writing the full expo-module.config.json
+ * 2. Configures iOS URL scheme for Google Sign-In (if env var is set)
+ * 3. Adds Android packaging exclusions to resolve dependency conflicts
+ */
+const withClerkExpo: ConfigPlugin = config => {
+ // Enable native modules first (writes expo-module.config.json)
+ config = withClerkNativeModules(config);
+ config = withClerkGoogleSignIn(config);
+ config = withClerkAndroidPackaging(config);
+ return config;
+};
+
+export default createRunOncePlugin(withClerkExpo, pkg.name, pkg.version);
diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx
index 6c81616a1fc..6635a8ebe29 100644
--- a/packages/expo/src/provider/ClerkProvider.tsx
+++ b/packages/expo/src/provider/ClerkProvider.tsx
@@ -3,8 +3,11 @@ import '../polyfills';
import { ClerkProvider as ClerkReactProvider } from '@clerk/react';
import type { Ui } from '@clerk/react/internal';
import * as WebBrowser from 'expo-web-browser';
+import { Platform } from 'react-native';
+import { useEffect, useRef } from 'react';
import type { TokenCache } from '../cache/types';
+import { useNativeAuthEvents } from '../hooks/useNativeAuthEvents';
import { isNative, isWeb } from '../utils/runtime';
import { getClerkInstance } from './singleton';
import type { BuildClerkOptions } from './singleton/types';
@@ -62,6 +65,179 @@ export function ClerkProvider(props: ClerkProviderProps(null);
+ const initStartedRef = useRef(false);
+ const sessionSyncedRef = useRef(false);
+
+ // Get the Clerk instance for syncing
+ const clerkInstance = isNative()
+ ? getClerkInstance({
+ publishableKey: pk,
+ tokenCache,
+ __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) {
+ initStartedRef.current = true;
+
+ const configureNativeClerk = async () => {
+ try {
+ const { requireNativeModule } = require('expo-modules-core');
+ const ClerkExpo = requireNativeModule('ClerkExpo');
+
+ if (ClerkExpo?.configure) {
+ await ClerkExpo.configure(pk);
+
+ if (!isMountedRef.current) {
+ return;
+ }
+
+ // Poll for native session (matching iOS's 3-second max wait)
+ const MAX_WAIT_MS = 3000;
+ const POLL_INTERVAL_MS = 100;
+ let sessionId: string | null = null;
+
+ for (let elapsed = 0; elapsed < MAX_WAIT_MS; elapsed += POLL_INTERVAL_MS) {
+ if (!isMountedRef.current) {
+ return;
+ }
+ if (ClerkExpo?.getSession) {
+ const nativeSession = await ClerkExpo.getSession();
+ sessionId = nativeSession?.sessionId;
+ if (sessionId) {
+ break;
+ }
+ }
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
+ }
+
+ if (!isMountedRef.current) {
+ return;
+ }
+
+ if (sessionId && clerkInstance) {
+ pendingNativeSessionRef.current = sessionId;
+
+ // Wait for clerk to be loaded before syncing
+ const clerkAny = clerkInstance as any;
+
+ const waitForLoad = (): Promise => {
+ return new Promise(resolve => {
+ if (clerkAny.loaded) {
+ resolve();
+ } else if (typeof clerkAny.addOnLoaded === 'function') {
+ clerkAny.addOnLoaded(() => resolve());
+ } else {
+ if (__DEV__) {
+ console.warn('[ClerkProvider] Clerk instance has no loaded property or addOnLoaded method');
+ }
+ resolve();
+ }
+ });
+ };
+
+ await waitForLoad();
+
+ if (!isMountedRef.current) {
+ return;
+ }
+
+ if (!sessionSyncedRef.current && typeof clerkInstance.setActive === 'function') {
+ sessionSyncedRef.current = true;
+ const pendingSession = pendingNativeSessionRef.current;
+
+ // If the native session is not in the client's sessions list,
+ // reload the client from the API so setActive can find it.
+ const sessionInClient = clerkInstance.client?.sessions?.some(
+ (s: { id: string }) => s.id === pendingSession,
+ );
+ if (!sessionInClient && typeof clerkAny.__internal_reloadInitialResources === 'function') {
+ await clerkAny.__internal_reloadInitialResources();
+ }
+
+ try {
+ await clerkInstance.setActive({ session: pendingSession });
+ } catch (err) {
+ console.error(`[ClerkProvider] Failed to sync native session:`, err);
+ }
+ }
+ }
+ }
+ } catch (error) {
+ const isNativeModuleNotFound = error instanceof Error && error.message.includes('Cannot find native module');
+ if (isNativeModuleNotFound) {
+ if (__DEV__) {
+ console.debug(
+ `[ClerkProvider] Native Clerk module not available. ` +
+ `To enable native features, add "@clerk/expo" to your app.json plugins array.`,
+ );
+ }
+ } else {
+ console.error(`[ClerkProvider] Failed to configure Clerk ${Platform.OS}:`, error);
+ }
+ }
+ };
+ configureNativeClerk();
+ }
+
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, [pk, clerkInstance]);
+
+ // Listen for native auth state changes and sync to JS SDK
+ const { nativeAuthState } = useNativeAuthEvents();
+
+ useEffect(() => {
+ if (!nativeAuthState || !clerkInstance) {
+ return;
+ }
+
+ const syncNativeAuthToJs = async () => {
+ try {
+ if (nativeAuthState.type === 'signedIn' && nativeAuthState.sessionId && clerkInstance.setActive) {
+ // 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();
+ }
+ } catch (error) {
+ console.error(`[ClerkProvider] Failed to sync native auth state:`, error);
+ }
+ };
+
+ syncNativeAuthToJs();
+ }, [nativeAuthState, clerkInstance]);
+
if (isWeb()) {
// This is needed in order for useOAuth to work correctly on web.
WebBrowser.maybeCompleteAuthSession();
@@ -75,16 +251,7 @@ export function ClerkProvider(props: ClerkProviderProps
+ isClerkRuntimeError(err) && err.code === 'network_error';
+
const retryInitilizeResourcesFromFAPI = async () => {
- const isClerkNetworkError = (err: unknown) => isClerkRuntimeError(err) && err.code === 'network_error';
try {
await __internal_clerk?.__internal_reloadInitialResources();
- } catch (err) {
+ } catch (err: unknown) {
// Retry after 3 seconds if the error is a network error or a 5xx error
if (isClerkNetworkError(err) || !is4xxError(err)) {
// Retry after 2 seconds if the error is a network error
@@ -123,9 +125,11 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
ClientResourceCache.init({ publishableKey, storage: createResourceCache });
SessionJWTCache.init({ publishableKey, storage: createResourceCache });
- __internal_clerk.addListener(({ client }) => {
+ // At this point __internal_clerk is guaranteed to be defined (just created above)
+ const clerk = __internal_clerk!;
+ clerk.addListener(({ client }) => {
// @ts-expect-error - This is an internal API
- const environment = __internal_clerk?.__internal_environment as EnvironmentResource;
+ const environment = clerk?.__internal_environment as EnvironmentResource;
if (environment) {
void EnvironmentResourceCache.save(environment.__internal_toSnapshot());
}
@@ -144,7 +148,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
}
});
- __internal_clerk.__internal_getCachedResources = async (): Promise<{
+ clerk.__internal_getCachedResources = async (): Promise<{
client: ClientJSONSnapshot | null;
environment: EnvironmentJSONSnapshot | null;
}> => {
@@ -195,6 +199,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
}
});
}
- return __internal_clerk;
+ // At this point __internal_clerk is guaranteed to be defined
+ return __internal_clerk!;
};
}
diff --git a/packages/expo/src/provider/singleton/singleton.web.ts b/packages/expo/src/provider/singleton/singleton.web.ts
index 3a307f6350b..e931cabe7bb 100644
--- a/packages/expo/src/provider/singleton/singleton.web.ts
+++ b/packages/expo/src/provider/singleton/singleton.web.ts
@@ -2,6 +2,13 @@ import type { BrowserClerk, HeadlessBrowserClerk } from '@clerk/react';
import type { BuildClerkOptions } from './types';
+// Augment the global Window type to include Clerk
+declare global {
+ interface Window {
+ Clerk?: HeadlessBrowserClerk | BrowserClerk;
+ }
+}
+
/**
* Access the existing Clerk instance from `window.Clerk` on the web.
* Unlike the native implementation, this does not create a new instance—it only returns the existing one set by ClerkProvider.
diff --git a/packages/expo/tsconfig.declarations.json b/packages/expo/tsconfig.declarations.json
index 30037049bb1..ac04a85ce27 100644
--- a/packages/expo/tsconfig.declarations.json
+++ b/packages/expo/tsconfig.declarations.json
@@ -11,5 +11,6 @@
"sourceMap": false,
"declarationDir": "./dist"
},
- "exclude": ["**/__tests__/**/*"]
+ "include": ["src"],
+ "exclude": ["**/__tests__/**/*", "app.plugin.js"]
}
diff --git a/packages/expo/tsconfig.json b/packages/expo/tsconfig.json
index 193a7812407..46556b4b9f3 100644
--- a/packages/expo/tsconfig.json
+++ b/packages/expo/tsconfig.json
@@ -23,5 +23,5 @@
"incremental": true,
"moduleSuffixes": [".web", ".ios", ".android", ".native", ""]
},
- "include": ["src"]
+ "include": ["src", "app.plugin.js"]
}
diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts
index db2cc3adeef..5bac83ddd06 100644
--- a/packages/react/src/isomorphicClerk.ts
+++ b/packages/react/src/isomorphicClerk.ts
@@ -178,13 +178,15 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
#publishableKey: string;
#eventBus = createClerkEventBus();
#stateProxy: StateProxy;
+ #initialized = false;
get publishableKey(): string {
return this.#publishableKey;
}
get loaded(): boolean {
- return this.clerkjs?.loaded || false;
+ // Consider loaded if either clerk is loaded OR we've initialized headlessly
+ return this.clerkjs?.loaded || this.#initialized;
}
get status(): ClerkStatus {
@@ -277,8 +279,71 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
this.#eventBus.emit(clerkEvents.Status, 'loading');
this.#eventBus.prioritizedOn(clerkEvents.Status, status => (this.#status = status));
- if (this.#publishableKey) {
+ // Only load entry chunks in standard browser environments (not React Native/headless)
+ if (this.#publishableKey && this.options.standardBrowser !== false) {
void this.getEntryChunks();
+ } else if (this.#publishableKey && this.options.Clerk) {
+ // For React Native/headless: initialize with the provided Clerk instance
+ void this.loadHeadlessClerk();
+ }
+ }
+
+ /**
+ * Initialize Clerk for headless/React Native environments where a Clerk instance is provided directly.
+ */
+ private loadHeadlessClerk(): void {
+ const clerk = isConstructor(this.options.Clerk)
+ ? new this.options.Clerk!(this.#publishableKey, { proxyUrl: this.proxyUrl, domain: this.domain })
+ : this.options.Clerk;
+
+ if (!clerk) {
+ this.#eventBus.emit(clerkEvents.Status, 'error');
+ return;
+ }
+
+ // Helper to finish initialization - marks as ready and triggers re-renders
+ const finishInit = () => {
+ this.#initialized = true;
+ this.clerkjs = clerk;
+ this.premountMethodCalls.forEach(cb => cb());
+ this.premountAddListenerCalls.forEach((listenerHandlers, listener) => {
+ const unsubscribe = clerk.addListener(listener);
+ listenerHandlers.nativeUnsubscribe = unsubscribe;
+ });
+
+ // Emit current state to all listeners so React context gets updated with actual values
+ // Use null instead of undefined for missing values to signal "loaded but empty"
+ const currentState = {
+ client: clerk.client ?? null,
+ session: clerk.session ?? null,
+ user: clerk.user ?? null,
+ organization: clerk.organization ?? null,
+ };
+ if (currentState.client) {
+ this.premountAddListenerCalls.forEach((_, listener) => {
+ listener(currentState as Resources);
+ });
+ }
+
+ // Emit status through eventBus
+ this.#eventBus.emit(clerkEvents.Status, 'ready');
+ this.emitLoaded();
+ };
+
+ // Try to load, but finish initialization regardless
+ if (!clerk.loaded) {
+ clerk
+ .load(this.options)
+ .then(() => finishInit())
+ .catch(err => {
+ if (__DEV__) {
+ console.error('Clerk: Failed to load:', err);
+ }
+ this.#eventBus.emit(clerkEvents.Status, 'error');
+ this.emitLoaded();
+ });
+ } else {
+ finishInit();
}
}
diff --git a/packages/upgrade/src/versions/core-3/changes/expo-native-components.md b/packages/upgrade/src/versions/core-3/changes/expo-native-components.md
new file mode 100644
index 00000000000..e7123ad111e
--- /dev/null
+++ b/packages/upgrade/src/versions/core-3/changes/expo-native-components.md
@@ -0,0 +1,32 @@
+---
+title: 'Native UI components available via `@clerk/expo/native`'
+packages: ['expo']
+matcher: '@clerk/expo'
+category: 'feature'
+---
+
+New native UI components are available for Clerk authentication in Expo apps. These components provide pre-built, native authentication experiences powered by [clerk-ios](https://github.com/clerk/clerk-ios) and [clerk-android](https://github.com/clerk/clerk-android).
+
+Import from the new `/native` entry point:
+
+```tsx
+import { AuthView, UserButton, UserProfileView } from '@clerk/expo/native';
+```
+
+**Available components:**
+
+- `AuthView` - Authentication flow (sign-in/sign-up)
+- `UserButton` - Avatar button that opens profile
+- `UserProfileView` - User profile and account management
+
+**Requirements:**
+
+1. Add the plugin to your `app.json`:
+ ```json
+ {
+ "expo": {
+ "plugins": ["@clerk/expo"]
+ }
+ }
+ ```
+2. Run `npx expo prebuild` to generate native code
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 72c2b119821..5b6b1d98d33 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -605,6 +605,9 @@ importers:
'@types/base-64':
specifier: ^1.0.2
version: 1.0.2
+ esbuild:
+ specifier: ^0.19.0
+ version: 0.19.12
expo-apple-authentication:
specifier: ^7.2.4
version: 7.2.4(expo@54.0.23(@babel/core@7.28.5)(@modelcontextprotocol/sdk@1.25.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(zod@3.25.76))(bufferutil@4.0.9)(graphql@16.12.0)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10))(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))
@@ -2279,102 +2282,204 @@ packages:
resolution: {integrity: sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==}
engines: {node: '>=18'}
+ '@esbuild/aix-ppc64@0.19.12':
+ resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [aix]
+
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
+ '@esbuild/android-arm64@0.19.12':
+ resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
'@esbuild/android-arm64@0.25.12':
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
+ '@esbuild/android-arm@0.19.12':
+ resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
'@esbuild/android-arm@0.25.12':
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
+ '@esbuild/android-x64@0.19.12':
+ resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
'@esbuild/android-x64@0.25.12':
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
+ '@esbuild/darwin-arm64@0.19.12':
+ resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
'@esbuild/darwin-arm64@0.25.12':
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
+ '@esbuild/darwin-x64@0.19.12':
+ resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
'@esbuild/darwin-x64@0.25.12':
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
+ '@esbuild/freebsd-arm64@0.19.12':
+ resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
'@esbuild/freebsd-arm64@0.25.12':
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
+ '@esbuild/freebsd-x64@0.19.12':
+ resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
'@esbuild/freebsd-x64@0.25.12':
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
+ '@esbuild/linux-arm64@0.19.12':
+ resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
'@esbuild/linux-arm64@0.25.12':
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
+ '@esbuild/linux-arm@0.19.12':
+ resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
'@esbuild/linux-arm@0.25.12':
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
+ '@esbuild/linux-ia32@0.19.12':
+ resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
'@esbuild/linux-ia32@0.25.12':
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
+ '@esbuild/linux-loong64@0.19.12':
+ resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
'@esbuild/linux-loong64@0.25.12':
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
+ '@esbuild/linux-mips64el@0.19.12':
+ resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
'@esbuild/linux-mips64el@0.25.12':
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
+ '@esbuild/linux-ppc64@0.19.12':
+ resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
'@esbuild/linux-ppc64@0.25.12':
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
+ '@esbuild/linux-riscv64@0.19.12':
+ resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
'@esbuild/linux-riscv64@0.25.12':
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
+ '@esbuild/linux-s390x@0.19.12':
+ resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
'@esbuild/linux-s390x@0.25.12':
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
+ '@esbuild/linux-x64@0.19.12':
+ resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
'@esbuild/linux-x64@0.25.12':
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
engines: {node: '>=18'}
@@ -2387,6 +2492,12 @@ packages:
cpu: [arm64]
os: [netbsd]
+ '@esbuild/netbsd-x64@0.19.12':
+ resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
'@esbuild/netbsd-x64@0.25.12':
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
engines: {node: '>=18'}
@@ -2399,6 +2510,12 @@ packages:
cpu: [arm64]
os: [openbsd]
+ '@esbuild/openbsd-x64@0.19.12':
+ resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
'@esbuild/openbsd-x64@0.25.12':
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
engines: {node: '>=18'}
@@ -2411,24 +2528,48 @@ packages:
cpu: [arm64]
os: [openharmony]
+ '@esbuild/sunos-x64@0.19.12':
+ resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
'@esbuild/sunos-x64@0.25.12':
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
+ '@esbuild/win32-arm64@0.19.12':
+ resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
'@esbuild/win32-arm64@0.25.12':
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
+ '@esbuild/win32-ia32@0.19.12':
+ resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
'@esbuild/win32-ia32@0.25.12':
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
+ '@esbuild/win32-x64@0.19.12':
+ resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
'@esbuild/win32-x64@0.25.12':
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
engines: {node: '>=18'}
@@ -7910,6 +8051,11 @@ packages:
resolution: {integrity: sha512-lNjylaAsJMprYg28zjUyBivP3y0ms9b7RJZ5tdhDUFLa3sCbqZw4wDnbFUSmnyZYWhCYDPxxp7KkXM2TXGw3PQ==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
+ esbuild@0.19.12:
+ resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==}
+ engines: {node: '>=12'}
+ hasBin: true
+
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
@@ -16761,81 +16907,150 @@ snapshots:
esquery: 1.6.0
jsdoc-type-pratt-parser: 4.1.0
+ '@esbuild/aix-ppc64@0.19.12':
+ optional: true
+
'@esbuild/aix-ppc64@0.25.12':
optional: true
+ '@esbuild/android-arm64@0.19.12':
+ optional: true
+
'@esbuild/android-arm64@0.25.12':
optional: true
+ '@esbuild/android-arm@0.19.12':
+ optional: true
+
'@esbuild/android-arm@0.25.12':
optional: true
+ '@esbuild/android-x64@0.19.12':
+ optional: true
+
'@esbuild/android-x64@0.25.12':
optional: true
+ '@esbuild/darwin-arm64@0.19.12':
+ optional: true
+
'@esbuild/darwin-arm64@0.25.12':
optional: true
+ '@esbuild/darwin-x64@0.19.12':
+ optional: true
+
'@esbuild/darwin-x64@0.25.12':
optional: true
+ '@esbuild/freebsd-arm64@0.19.12':
+ optional: true
+
'@esbuild/freebsd-arm64@0.25.12':
optional: true
+ '@esbuild/freebsd-x64@0.19.12':
+ optional: true
+
'@esbuild/freebsd-x64@0.25.12':
optional: true
+ '@esbuild/linux-arm64@0.19.12':
+ optional: true
+
'@esbuild/linux-arm64@0.25.12':
optional: true
+ '@esbuild/linux-arm@0.19.12':
+ optional: true
+
'@esbuild/linux-arm@0.25.12':
optional: true
+ '@esbuild/linux-ia32@0.19.12':
+ optional: true
+
'@esbuild/linux-ia32@0.25.12':
optional: true
+ '@esbuild/linux-loong64@0.19.12':
+ optional: true
+
'@esbuild/linux-loong64@0.25.12':
optional: true
+ '@esbuild/linux-mips64el@0.19.12':
+ optional: true
+
'@esbuild/linux-mips64el@0.25.12':
optional: true
+ '@esbuild/linux-ppc64@0.19.12':
+ optional: true
+
'@esbuild/linux-ppc64@0.25.12':
optional: true
+ '@esbuild/linux-riscv64@0.19.12':
+ optional: true
+
'@esbuild/linux-riscv64@0.25.12':
optional: true
+ '@esbuild/linux-s390x@0.19.12':
+ optional: true
+
'@esbuild/linux-s390x@0.25.12':
optional: true
+ '@esbuild/linux-x64@0.19.12':
+ optional: true
+
'@esbuild/linux-x64@0.25.12':
optional: true
'@esbuild/netbsd-arm64@0.25.12':
optional: true
+ '@esbuild/netbsd-x64@0.19.12':
+ optional: true
+
'@esbuild/netbsd-x64@0.25.12':
optional: true
'@esbuild/openbsd-arm64@0.25.12':
optional: true
+ '@esbuild/openbsd-x64@0.19.12':
+ optional: true
+
'@esbuild/openbsd-x64@0.25.12':
optional: true
'@esbuild/openharmony-arm64@0.25.12':
optional: true
+ '@esbuild/sunos-x64@0.19.12':
+ optional: true
+
'@esbuild/sunos-x64@0.25.12':
optional: true
+ '@esbuild/win32-arm64@0.19.12':
+ optional: true
+
'@esbuild/win32-arm64@0.25.12':
optional: true
+ '@esbuild/win32-ia32@0.19.12':
+ optional: true
+
'@esbuild/win32-ia32@0.25.12':
optional: true
+ '@esbuild/win32-x64@0.19.12':
+ optional: true
+
'@esbuild/win32-x64@0.25.12':
optional: true
@@ -24062,6 +24277,32 @@ snapshots:
esbuild-plugin-file-path-extensions@2.1.4: {}
+ esbuild@0.19.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.19.12
+ '@esbuild/android-arm': 0.19.12
+ '@esbuild/android-arm64': 0.19.12
+ '@esbuild/android-x64': 0.19.12
+ '@esbuild/darwin-arm64': 0.19.12
+ '@esbuild/darwin-x64': 0.19.12
+ '@esbuild/freebsd-arm64': 0.19.12
+ '@esbuild/freebsd-x64': 0.19.12
+ '@esbuild/linux-arm': 0.19.12
+ '@esbuild/linux-arm64': 0.19.12
+ '@esbuild/linux-ia32': 0.19.12
+ '@esbuild/linux-loong64': 0.19.12
+ '@esbuild/linux-mips64el': 0.19.12
+ '@esbuild/linux-ppc64': 0.19.12
+ '@esbuild/linux-riscv64': 0.19.12
+ '@esbuild/linux-s390x': 0.19.12
+ '@esbuild/linux-x64': 0.19.12
+ '@esbuild/netbsd-x64': 0.19.12
+ '@esbuild/openbsd-x64': 0.19.12
+ '@esbuild/sunos-x64': 0.19.12
+ '@esbuild/win32-arm64': 0.19.12
+ '@esbuild/win32-ia32': 0.19.12
+ '@esbuild/win32-x64': 0.19.12
+
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12