diff --git a/.changeset/fix-native-bridge-quality.md b/.changeset/fix-native-bridge-quality.md
new file mode 100644
index 00000000000..9a44d458408
--- /dev/null
+++ b/.changeset/fix-native-bridge-quality.md
@@ -0,0 +1,7 @@
+---
+'@clerk/expo': minor
+---
+
+- Add native React Native components (AuthView, UserButton, UserProfileView) with `useUserProfileModal()` hook
+- Add native Google Sign-In via Credential Manager (Android) and ASAuthorization (iOS)
+- Update to Core-3 Signal APIs
diff --git a/.gitignore b/.gitignore
index dd7130c8543..8ae9cbf415e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -91,6 +91,10 @@ sessions.pem
# pkglab / Verdaccio local registry
.verdaccio
+# yalc
+.yalc/
+yalc.lock
+
# Release preflight
.release-artifacts/
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..a57de111813 100644
--- a/packages/expo/android/build.gradle
+++ b/packages/expo/android/build.gradle
@@ -1,5 +1,13 @@
-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'
+}
+
+// Required for React Native codegen to generate Fabric component descriptors
+if (project.hasProperty("newArchEnabled") && project.newArchEnabled == "true") {
+ apply plugin: "com.facebook.react"
+}
group = 'com.clerk.expo'
version = '1.0.0'
@@ -10,6 +18,11 @@ ext {
credentialsVersion = "1.3.0"
googleIdVersion = "1.1.1"
kotlinxCoroutinesVersion = "1.7.3"
+ clerkAndroidApiVersion = "1.0.6"
+ clerkAndroidUiVersion = "1.0.9"
+ composeVersion = "1.7.0"
+ activityComposeVersion = "1.9.0"
+ lifecycleVersion = "2.8.0"
}
def safeExtGet(prop, fallback) {
@@ -17,7 +30,7 @@ def safeExtGet(prop, fallback) {
}
android {
- namespace "expo.modules.clerk.googlesignin"
+ namespace "expo.modules.clerk"
compileSdk safeExtGet("compileSdkVersion", 36)
@@ -41,18 +54,32 @@ android {
kotlinOptions {
jvmTarget = "17"
+ // clerk-android transitive deps (e.g. telemetry-api) still ship Kotlin 2.3.x metadata
+ freeCompilerArgs += ['-Xskip-metadata-version-check']
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ packaging {
+ resources {
+ excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
+ }
}
sourceSets {
main {
- java.srcDirs = ['src/main/java']
+ java.srcDirs = ['src/main/java', "${project.buildDir}/generated/source/codegen/java"]
}
}
}
+// Note: kotlin-stdlib exclusions are handled in the clerk-android-ui dependency declaration
+
dependencies {
- // Expo modules core
- implementation project(':expo-modules-core')
+ // React Native
+ implementation 'com.facebook.react:react-native:+'
// Credential Manager for Google Sign-In with nonce support
implementation "androidx.credentials:credentials:$credentialsVersion"
@@ -61,4 +88,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..acd934830de
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt
@@ -0,0 +1,306 @@
+package expo.modules.clerk
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import java.util.concurrent.atomic.AtomicBoolean
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.clerk.api.Clerk
+import com.clerk.api.signin.SignIn
+import com.clerk.api.signin.prepareSecondFactor
+import com.clerk.api.signup.SignUp
+import com.clerk.api.signup.prepareVerification
+import com.clerk.api.network.serialization.onSuccess
+import com.clerk.api.network.serialization.onFailure
+import com.clerk.api.network.serialization.errorMessage
+import com.clerk.ui.auth.AuthView
+import kotlinx.coroutines.delay
+
+/**
+ * Activity that hosts Clerk's AuthView Compose component.
+ *
+ * This activity is launched from ClerkExpoModule to present a full-screen
+ * authentication modal (sign-in, sign-up, or combined flow).
+ *
+ * Intent extras:
+ * - "mode": String - "signIn", "signUp", or "signInOrUp" (default)
+ * - "dismissable": Boolean - whether back press dismisses (default: true)
+ *
+ * Result:
+ * - RESULT_OK: Auth completed successfully (session is available via Clerk.session)
+ * - RESULT_CANCELED: User dismissed the modal
+ */
+class ClerkAuthActivity : ComponentActivity() {
+
+ companion object {
+ private const val TAG = "ClerkAuthActivity"
+ private const val CLIENT_SYNC_MAX_ATTEMPTS = 30
+ private const val CLIENT_SYNC_INTERVAL_MS = 100L
+ private const val POLL_INTERVAL_MS = 500L
+
+ private fun debugLog(tag: String, message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d(tag, message)
+ }
+ }
+ }
+
+ private val authCompleteGuard = AtomicBoolean(false)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val mode = intent.getStringExtra(ClerkExpoModule.EXTRA_MODE) ?: "signInOrUp"
+ val dismissable = intent.getBooleanExtra(ClerkExpoModule.EXTRA_DISMISSABLE, true)
+
+ // Track if we had a session when we started (to detect new sign-in)
+ val initialSession = Clerk.session
+ debugLog(TAG, "onCreate - hasInitialSession: ${initialSession != null}, mode: $mode")
+
+ setContent {
+ // Observe initialization state
+ val isInitialized by Clerk.isInitialized.collectAsStateWithLifecycle()
+
+ // Observe both session and user state for completion
+ val session by Clerk.sessionFlow.collectAsStateWithLifecycle()
+ val user by Clerk.userFlow.collectAsStateWithLifecycle()
+
+ // Track if the client has been synced (environment is ready)
+ // We need to wait for the client to sync before showing AuthView
+ var isClientReady by remember { mutableStateOf(false) }
+
+ // Track when auth is complete to hide AuthView before finishing
+ // This prevents the "NavDisplay backstack cannot be empty" crash
+ var isAuthComplete by remember { mutableStateOf(false) }
+
+ // Wait for SDK to be fully initialized AND client to sync
+ // The client sync happens after isInitialized becomes true
+ LaunchedEffect(isInitialized) {
+ if (isInitialized) {
+ // Give the client a moment to sync after initialization
+ // The SDK needs time to fetch the environment configuration
+ var attempts = 0
+ while (attempts < CLIENT_SYNC_MAX_ATTEMPTS) {
+ val client = Clerk.client
+ if (client != null) {
+ debugLog(TAG, "Client is ready")
+ isClientReady = true
+ break
+ }
+ delay(CLIENT_SYNC_INTERVAL_MS)
+ attempts++
+ }
+ if (!isClientReady) {
+ Log.w(TAG, "Client did not become ready after 3 seconds, showing AuthView anyway")
+ isClientReady = true
+ }
+ }
+ }
+
+ // Track last signUp ID to detect when a new signUp is created
+ var lastSignUpId by remember { mutableStateOf(null) }
+ // Track if we've already triggered prepareVerification for this signUp
+ var preparedSignUpId by remember { mutableStateOf(null) }
+
+ // Track if we've already triggered prepareSecondFactor for this signIn
+ var preparedSecondFactorSignInId by remember { mutableStateOf(null) }
+
+ // Monitor signUp state changes and manually trigger prepareVerification
+ LaunchedEffect(isClientReady) {
+ if (isClientReady) {
+ while (true) {
+ delay(POLL_INTERVAL_MS)
+ val client = Clerk.client
+ val signUp = client?.signUp
+
+ if (signUp != null && signUp.id != lastSignUpId) {
+ lastSignUpId = signUp.id
+ debugLog(TAG, "New signUp detected, status: ${signUp.status}")
+ }
+
+ // Manually trigger prepareVerification if needed
+ // This is a workaround for clerk-android-ui not calling prepareVerification
+ if (signUp != null &&
+ signUp.id != preparedSignUpId &&
+ signUp.emailAddress != null &&
+ signUp.status == SignUp.Status.MISSING_REQUIREMENTS) {
+
+ val emailVerification = signUp.verifications?.get("email_address")
+ // Only prepare if email is unverified
+ if (emailVerification?.status?.name == "UNVERIFIED") {
+ preparedSignUpId = signUp.id
+
+ try {
+ val result = signUp.prepareVerification(
+ SignUp.PrepareVerificationParams.Strategy.EmailCode()
+ )
+ result
+ .onSuccess {
+ debugLog(TAG, "prepareVerification succeeded")
+ }
+ .onFailure { error ->
+ Log.e(TAG, "prepareVerification failed: ${error.errorMessage}")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "prepareVerification exception: ${e.message}")
+ }
+ }
+ }
+
+ // Manually trigger prepareSecondFactor for MFA if needed
+ // This is a workaround for clerk-android-ui not calling prepareSecondFactor
+ val signIn = client?.signIn
+ if (signIn != null &&
+ signIn.id != preparedSecondFactorSignInId &&
+ signIn.status == SignIn.Status.NEEDS_SECOND_FACTOR) {
+
+ preparedSecondFactorSignInId = signIn.id
+
+ try {
+ val result = signIn.prepareSecondFactor()
+ result
+ .onSuccess { updatedSignIn ->
+ debugLog(TAG, "prepareSecondFactor succeeded, status: ${updatedSignIn.status}")
+ }
+ .onFailure { error ->
+ Log.e(TAG, "prepareSecondFactor failed: ${error.errorMessage}")
+ // Reset so we can retry
+ preparedSecondFactorSignInId = null
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "prepareSecondFactor exception: ${e.message}")
+ // Reset so we can retry
+ preparedSecondFactorSignInId = null
+ }
+ }
+
+ // Check if auth completed - finish activity immediately
+ val currentSession = Clerk.session
+ if (currentSession != null && authCompleteGuard.compareAndSet(false, true)) {
+ isAuthComplete = true
+
+ val resultIntent = Intent().apply {
+ putExtra("sessionId", currentSession.id)
+ putExtra("userId", currentSession.user?.id ?: Clerk.user?.id)
+ }
+ setResult(Activity.RESULT_OK, resultIntent)
+ finish()
+ break
+ }
+ }
+ }
+ }
+
+ // Backup: Also listen for session via Flow (in case polling misses it)
+ LaunchedEffect(session) {
+ if (session != null && initialSession == null && authCompleteGuard.compareAndSet(false, true)) {
+ // Mark auth as complete FIRST to hide AuthView
+ // This prevents the "NavDisplay backstack cannot be empty" crash
+ isAuthComplete = true
+
+ // Small delay to let the UI update before finishing
+ delay(100)
+
+ // Auth completed - return session info
+ val resultIntent = Intent().apply {
+ putExtra("sessionId", session?.id)
+ putExtra("userId", session?.user?.id ?: user?.id)
+ }
+ setResult(Activity.RESULT_OK, resultIntent)
+ finish()
+ }
+ }
+
+ // Handle back press
+ if (dismissable) {
+ BackHandler {
+ setResult(Activity.RESULT_CANCELED)
+ finish()
+ }
+ } else {
+ // Block back press when not dismissable
+ BackHandler { /* Do nothing */ }
+ }
+
+ // Render Clerk's AuthView in a Material3 surface
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ when {
+ isAuthComplete -> {
+ // Auth completed - show success indicator while finishing
+ // This prevents AuthView from crashing with empty navigation backstack
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp)
+ )
+ Text(
+ text = "Signed in!",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+ isClientReady -> {
+ // Client is ready, show AuthView
+ AuthView(
+ modifier = Modifier.fillMaxSize(),
+ clerkTheme = 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..60280542e27
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt
@@ -0,0 +1,155 @@
+package expo.modules.clerk
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.util.Log
+import android.widget.FrameLayout
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Recomposer
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.AndroidUiDispatcher
+import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.savedstate.compose.LocalSavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.clerk.api.Clerk
+import com.clerk.ui.auth.AuthView
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.uimanager.events.RCTEventEmitter
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+private const val TAG = "ClerkAuthExpoView"
+
+private fun debugLog(tag: String, message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d(tag, message)
+ }
+}
+
+class ClerkAuthNativeView(context: Context) : FrameLayout(context) {
+ var mode: String = "signInOrUp"
+ var isDismissable: Boolean = true
+
+ private val activity: ComponentActivity? = findActivity(context)
+
+ private var recomposer: Recomposer? = null
+ private var recomposerJob: kotlinx.coroutines.Job? = null
+
+ private val composeView = ComposeView(context).also { view ->
+ activity?.let { act ->
+ view.setViewTreeLifecycleOwner(act)
+ view.setViewTreeViewModelStoreOwner(act)
+ view.setViewTreeSavedStateRegistryOwner(act)
+
+ // Create an explicit Recomposer to bypass windowRecomposer resolution.
+ // In Compose 1.7+, windowRecomposer looks at rootView which may not have
+ // lifecycle owners in React Native Fabric's detached view trees.
+ val recomposerContext = AndroidUiDispatcher.Main
+ val newRecomposer = Recomposer(recomposerContext)
+ recomposer = newRecomposer
+ view.setParentCompositionContext(newRecomposer)
+ val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob())
+ recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job]
+ scope.launch {
+ newRecomposer.runRecomposeAndApplyChanges()
+ }
+ }
+ addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
+ }
+
+ override fun onDetachedFromWindow() {
+ recomposer?.cancel()
+ recomposerJob?.cancel()
+ super.onDetachedFromWindow()
+ }
+
+ // Track the initial session to detect new sign-ins
+ private var initialSessionId: String? = Clerk.session?.id
+
+ fun setupView() {
+ debugLog(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) {
+ debugLog(TAG, "Auth completed - session present: true")
+ sendEvent("signInCompleted", mapOf(
+ "sessionId" to currentSession.id,
+ "type" to "signIn"
+ ))
+ }
+ }
+
+ // Provide the Activity as ViewModelStoreOwner so Clerk's viewModel() calls work
+ val content = @androidx.compose.runtime.Composable {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ AuthView(
+ modifier = Modifier.fillMaxSize(),
+ clerkTheme = null
+ )
+ }
+ }
+ }
+
+ if (activity != null) {
+ CompositionLocalProvider(
+ LocalViewModelStoreOwner provides activity,
+ LocalLifecycleOwner provides activity,
+ LocalSavedStateRegistryOwner provides activity,
+ ) {
+ content()
+ }
+ } else {
+ Log.e(TAG, "No ComponentActivity found!")
+ content()
+ }
+ }
+ }
+
+ private fun sendEvent(type: String, data: Map) {
+ val reactContext = context as? ReactContext ?: return
+ val eventData = Arguments.createMap().apply {
+ putString("type", type)
+ // Serialize data as JSON string for codegen event
+ val jsonString = try {
+ org.json.JSONObject(data).toString()
+ } catch (e: Exception) {
+ "{}"
+ }
+ putString("data", jsonString)
+ }
+ reactContext.getJSModule(RCTEventEmitter::class.java)
+ .receiveEvent(id, "onAuthEvent", eventData)
+ }
+
+ companion object {
+ fun findActivity(context: Context): ComponentActivity? {
+ var ctx: Context? = context
+ while (ctx != null) {
+ if (ctx is ComponentActivity) return ctx
+ ctx = (ctx as? ContextWrapper)?.baseContext
+ }
+ return null
+ }
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt
new file mode 100644
index 00000000000..9ff989d9ea8
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt
@@ -0,0 +1,38 @@
+package expo.modules.clerk
+
+import com.facebook.react.common.MapBuilder
+import com.facebook.react.uimanager.SimpleViewManager
+import com.facebook.react.uimanager.ThemedReactContext
+import com.facebook.react.uimanager.annotations.ReactProp
+import com.facebook.react.viewmanagers.ClerkAuthViewManagerInterface
+
+class ClerkAuthViewManager : SimpleViewManager(),
+ ClerkAuthViewManagerInterface {
+
+ override fun getName(): String = "ClerkAuthView"
+
+ override fun createViewInstance(reactContext: ThemedReactContext): ClerkAuthNativeView {
+ return ClerkAuthNativeView(reactContext)
+ }
+
+ @ReactProp(name = "mode")
+ override fun setMode(view: ClerkAuthNativeView, mode: String?) {
+ view.mode = mode ?: "signInOrUp"
+ view.setupView()
+ }
+
+ @ReactProp(name = "isDismissable")
+ override fun setIsDismissable(view: ClerkAuthNativeView, isDismissable: Boolean) {
+ view.isDismissable = isDismissable
+ view.setupView()
+ }
+
+ override fun getExportedCustomBubblingEventTypeConstants(): MutableMap? {
+ return MapBuilder.builder()
+ .put("onAuthEvent", MapBuilder.of(
+ "phasedRegistrationNames",
+ MapBuilder.of("bubbled", "onAuthEvent")
+ ))
+ .build() as MutableMap
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
new file mode 100644
index 00000000000..f08753c21fe
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
@@ -0,0 +1,340 @@
+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 com.facebook.react.bridge.ActivityEventListener
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.bridge.ReadableMap
+import com.facebook.react.bridge.WritableNativeMap
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+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)
+ }
+}
+
+class ClerkExpoModule(reactContext: ReactApplicationContext) :
+ NativeClerkModuleSpec(reactContext),
+ ActivityEventListener {
+
+ companion object {
+ const val CLERK_AUTH_REQUEST_CODE = 9001
+ const val CLERK_PROFILE_REQUEST_CODE = 9002
+
+ // Intent extras
+ const val EXTRA_DISMISSABLE = "dismissable"
+ const val EXTRA_PUBLISHABLE_KEY = "publishableKey"
+ const val EXTRA_MODE = "mode"
+
+ // Result extras
+ const val RESULT_SESSION_ID = "sessionId"
+ const val RESULT_CANCELLED = "cancelled"
+
+ // Pending promises for activity results
+ private var pendingAuthPromise: Promise? = null
+ private var pendingProfilePromise: Promise? = null
+
+ // Store publishable key for passing to activities
+ private var publishableKey: String? = null
+ }
+
+ private val coroutineScope = CoroutineScope(Dispatchers.Main)
+
+ init {
+ reactContext.addActivityEventListener(this)
+ }
+
+ override fun getName(): String = "ClerkExpo"
+
+ // MARK: - configure
+
+ @ReactMethod
+ override fun configure(pubKey: String, bearerToken: String?, promise: Promise) {
+ coroutineScope.launch {
+ try {
+ publishableKey = pubKey
+
+ // If the JS SDK has a bearer token, write it to the native SDK's
+ // SharedPreferences so both SDKs share the same Clerk API client.
+ if (!bearerToken.isNullOrEmpty()) {
+ reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
+ .edit()
+ .putString("DEVICE_TOKEN", bearerToken)
+ .apply()
+ debugLog(TAG, "configure - wrote JS bearer token to native SharedPreferences")
+ }
+
+ Clerk.initialize(reactApplicationContext, 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("E_TIMEOUT", message)
+ return@launch
+ }
+
+ // Check for initialization errors
+ val error = Clerk.initializationError.value
+ if (error != null) {
+ promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}")
+ } else {
+ promise.resolve(null)
+ }
+ } catch (e: Exception) {
+ promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e)
+ }
+ }
+ }
+
+ // MARK: - presentAuth
+
+ @ReactMethod
+ override fun presentAuth(options: ReadableMap, promise: Promise) {
+ val activity = getCurrentActivity() ?: run {
+ promise.reject("E_ACTIVITY_UNAVAILABLE", "No activity available to present Clerk UI.")
+ return
+ }
+
+ if (!Clerk.isInitialized.value) {
+ promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
+ return
+ }
+
+ // Check if user is already signed in
+ if (Clerk.session != null) {
+ promise.reject("already_signed_in", "User is already signed in")
+ return
+ }
+
+ pendingAuthPromise?.reject("E_SUPERSEDED", "Auth presentation was superseded")
+ pendingAuthPromise = promise
+
+ val mode = if (options.hasKey("mode")) options.getString("mode") ?: "signInOrUp" else "signInOrUp"
+ val dismissable = if (options.hasKey("dismissable")) options.getBoolean("dismissable") else true
+
+ val intent = Intent(activity, ClerkAuthActivity::class.java).apply {
+ putExtra(EXTRA_MODE, mode)
+ putExtra(EXTRA_DISMISSABLE, dismissable)
+ }
+
+ activity.startActivityForResult(intent, CLERK_AUTH_REQUEST_CODE)
+ }
+
+ // MARK: - presentUserProfile
+
+ @ReactMethod
+ override fun presentUserProfile(options: ReadableMap, promise: Promise) {
+ val activity = getCurrentActivity() ?: run {
+ promise.reject("E_ACTIVITY_UNAVAILABLE", "No activity available to present Clerk UI.")
+ return
+ }
+
+ if (!Clerk.isInitialized.value) {
+ promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
+ return
+ }
+
+ pendingProfilePromise?.reject("E_SUPERSEDED", "Profile presentation was superseded")
+ pendingProfilePromise = promise
+
+ val dismissable = if (options.hasKey("dismissable")) options.getBoolean("dismissable") else true
+
+ val intent = Intent(activity, ClerkUserProfileActivity::class.java).apply {
+ putExtra(EXTRA_DISMISSABLE, dismissable)
+ putExtra(EXTRA_PUBLISHABLE_KEY, publishableKey)
+ }
+
+ activity.startActivityForResult(intent, CLERK_PROFILE_REQUEST_CODE)
+ }
+
+ // MARK: - getSession
+
+ @ReactMethod
+ override fun getSession(promise: Promise) {
+ if (!Clerk.isInitialized.value) {
+ promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
+ return
+ }
+
+ val session = Clerk.session
+ val user = Clerk.user
+
+ debugLog(TAG, "getSession - hasSession: ${session != null}, hasUser: ${user != null}")
+
+ val result = WritableNativeMap()
+
+ session?.let {
+ val sessionMap = WritableNativeMap()
+ sessionMap.putString("id", it.id)
+ sessionMap.putString("status", it.status.name)
+ sessionMap.putString("userId", it.user?.id)
+ result.putMap("session", sessionMap)
+ }
+
+ user?.let {
+ val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId }
+ val primaryPhone = it.phoneNumbers.find { p -> p.id == it.primaryPhoneNumberId }
+
+ val userMap = WritableNativeMap()
+ userMap.putString("id", it.id)
+ userMap.putString("firstName", it.firstName)
+ userMap.putString("lastName", it.lastName)
+ userMap.putString("imageUrl", it.imageUrl)
+ userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress)
+ userMap.putString("primaryPhoneNumber", primaryPhone?.phoneNumber)
+ result.putMap("user", userMap)
+ }
+
+ promise.resolve(result)
+ }
+
+ // MARK: - getClientToken
+
+ @ReactMethod
+ override fun getClientToken(promise: Promise) {
+ try {
+ val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
+ val deviceToken = prefs.getString("DEVICE_TOKEN", null)
+ debugLog(TAG, "getClientToken - deviceToken: ${if (deviceToken != null) "found" else "null"}")
+ promise.resolve(deviceToken)
+ } catch (e: Exception) {
+ debugLog(TAG, "getClientToken failed: ${e.message}")
+ promise.resolve(null)
+ }
+ }
+
+ // MARK: - signOut
+
+ @ReactMethod
+ override fun signOut(promise: Promise) {
+ if (!Clerk.isInitialized.value) {
+ promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
+ return
+ }
+
+ coroutineScope.launch {
+ try {
+ Clerk.auth.signOut()
+ promise.resolve(null)
+ } catch (e: Exception) {
+ promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign out failed", e)
+ }
+ }
+ }
+
+ // MARK: - Activity Result Handling
+
+ override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
+ when (requestCode) {
+ CLERK_AUTH_REQUEST_CODE -> handleAuthResult(resultCode, data)
+ CLERK_PROFILE_REQUEST_CODE -> handleProfileResult(resultCode, data)
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ // Not used
+ }
+
+ private fun handleAuthResult(resultCode: Int, data: Intent?) {
+ 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 - hasSession: ${session != null}, hasUser: ${user != null}")
+
+ val result = WritableNativeMap()
+
+ // Top-level sessionId for JS SDK compatibility (matches iOS response format)
+ result.putString("sessionId", session?.id)
+
+ session?.let {
+ val sessionMap = WritableNativeMap()
+ sessionMap.putString("id", it.id)
+ sessionMap.putString("status", it.status.name)
+ sessionMap.putString("userId", it.user?.id)
+ result.putMap("session", sessionMap)
+ }
+
+ user?.let {
+ val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId }
+
+ val userMap = WritableNativeMap()
+ userMap.putString("id", it.id)
+ userMap.putString("firstName", it.firstName)
+ userMap.putString("lastName", it.lastName)
+ userMap.putString("imageUrl", it.imageUrl)
+ userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress)
+ result.putMap("user", userMap)
+ }
+
+ promise.resolve(result)
+ } else {
+ debugLog(TAG, "handleAuthResult - user cancelled")
+ val result = WritableNativeMap()
+ result.putBoolean("cancelled", true)
+ promise.resolve(result)
+ }
+ }
+
+ private fun handleProfileResult(resultCode: Int, data: Intent?) {
+ val promise = pendingProfilePromise ?: return
+ pendingProfilePromise = null
+
+ // Profile always returns current session state
+ val session = Clerk.session
+ val user = Clerk.user
+
+ val result = WritableNativeMap()
+
+ session?.let {
+ val sessionMap = WritableNativeMap()
+ sessionMap.putString("id", it.id)
+ sessionMap.putString("status", it.status.name)
+ sessionMap.putString("userId", it.user?.id)
+ result.putMap("session", sessionMap)
+ }
+
+ user?.let {
+ val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId }
+
+ val userMap = WritableNativeMap()
+ userMap.putString("id", it.id)
+ userMap.putString("firstName", it.firstName)
+ userMap.putString("lastName", it.lastName)
+ userMap.putString("imageUrl", it.imageUrl)
+ userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress)
+ result.putMap("user", userMap)
+ }
+
+ result.putBoolean("dismissed", resultCode == Activity.RESULT_CANCELED)
+
+ promise.resolve(result)
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt
new file mode 100644
index 00000000000..9a97309ac5e
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt
@@ -0,0 +1,43 @@
+package expo.modules.clerk
+
+import com.facebook.react.TurboReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.module.model.ReactModuleInfo
+import com.facebook.react.module.model.ReactModuleInfoProvider
+import com.facebook.react.uimanager.ViewManager
+
+class ClerkPackage : TurboReactPackage() {
+
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
+ return when (name) {
+ NativeClerkModuleSpec.NAME -> ClerkExpoModule(reactContext)
+ NativeClerkGoogleSignInSpec.NAME -> expo.modules.clerk.googlesignin.ClerkGoogleSignInModule(reactContext)
+ else -> null
+ }
+ }
+
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
+ return ReactModuleInfoProvider {
+ mapOf(
+ NativeClerkModuleSpec.NAME to ReactModuleInfo(
+ NativeClerkModuleSpec.NAME,
+ ClerkExpoModule::class.java.name,
+ false, false, true, false, true
+ ),
+ NativeClerkGoogleSignInSpec.NAME to ReactModuleInfo(
+ NativeClerkGoogleSignInSpec.NAME,
+ expo.modules.clerk.googlesignin.ClerkGoogleSignInModule::class.java.name,
+ false, false, true, false, true
+ ),
+ )
+ }
+ }
+
+ override fun createViewManagers(reactContext: ReactApplicationContext): List> {
+ return listOf(
+ ClerkAuthViewManager(),
+ ClerkUserProfileViewManager(),
+ )
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt
new file mode 100644
index 00000000000..db96f1a9097
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt
@@ -0,0 +1,119 @@
+package expo.modules.clerk
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.clerk.api.Clerk
+import com.clerk.ui.userprofile.UserProfileView
+
+/**
+ * Activity that hosts the Clerk UserProfileView composable.
+ * Presents the native user profile UI and returns the result when dismissed.
+ */
+class ClerkUserProfileActivity : ComponentActivity() {
+
+ companion object {
+ private const val TAG = "ClerkUserProfileActivity"
+
+ private fun debugLog(tag: String, message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d(tag, message)
+ }
+ }
+ }
+
+ private var dismissed = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val dismissable = intent.getBooleanExtra(ClerkExpoModule.EXTRA_DISMISSABLE, true)
+ val publishableKey = intent.getStringExtra(ClerkExpoModule.EXTRA_PUBLISHABLE_KEY)
+
+ debugLog(TAG, "onCreate - isInitialized: ${Clerk.isInitialized.value}")
+ debugLog(TAG, "onCreate - hasSession: ${Clerk.session != null}, hasUser: ${Clerk.user != null}")
+
+ // Initialize Clerk if not already initialized
+ if (publishableKey != null && !Clerk.isInitialized.value) {
+ debugLog(TAG, "Initializing Clerk...")
+ Clerk.initialize(applicationContext, publishableKey)
+ }
+
+ setContent {
+ // Observe user state changes
+ val user by Clerk.userFlow.collectAsStateWithLifecycle()
+ val session by Clerk.sessionFlow.collectAsStateWithLifecycle()
+
+ // Track if we had a session when the profile opened (to detect sign-out)
+ var hadSession by remember { mutableStateOf(Clerk.session != null) }
+
+ // Log when user/session state changes
+ LaunchedEffect(user, session) {
+ debugLog(TAG, "State changed - hasSession: ${session != null}, hasUser: ${user != null}")
+ }
+
+ // Detect sign-out: if we had a session and now it's null, user signed out
+ LaunchedEffect(session) {
+ if (hadSession && session == null) {
+ debugLog(TAG, "Sign-out detected - session became null, 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 via onBackPressedDispatcher (replaces deprecated onBackPressed)
+ onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (dismissable) {
+ finishWithSuccess()
+ }
+ // Otherwise ignore back press
+ }
+ })
+ }
+
+ private fun finishWithSuccess() {
+ if (dismissed) return
+ dismissed = true
+
+ val result = Intent()
+ result.putExtra(ClerkExpoModule.RESULT_SESSION_ID, Clerk.session?.id)
+ result.putExtra(ClerkExpoModule.RESULT_CANCELLED, false)
+ setResult(Activity.RESULT_OK, result)
+ finish()
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt
new file mode 100644
index 00000000000..dd770bee4f5
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt
@@ -0,0 +1,132 @@
+package expo.modules.clerk
+
+import android.content.Context
+import android.util.Log
+import android.widget.FrameLayout
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Recomposer
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.AndroidUiDispatcher
+import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.savedstate.compose.LocalSavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.clerk.api.Clerk
+import com.clerk.ui.userprofile.UserProfileView
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.uimanager.events.RCTEventEmitter
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+private const val TAG = "ClerkUserProfileExpoView"
+
+class ClerkUserProfileNativeView(context: Context) : FrameLayout(context) {
+ var isDismissable: Boolean = true
+
+ private val activity = ClerkAuthNativeView.findActivity(context)
+
+ private var recomposer: Recomposer? = null
+ private var recomposerJob: kotlinx.coroutines.Job? = null
+
+ private val composeView = ComposeView(context).also { view ->
+ activity?.let { act ->
+ view.setViewTreeLifecycleOwner(act)
+ view.setViewTreeViewModelStoreOwner(act)
+ view.setViewTreeSavedStateRegistryOwner(act)
+
+ val recomposerContext = AndroidUiDispatcher.Main
+ val newRecomposer = Recomposer(recomposerContext)
+ recomposer = newRecomposer
+ view.setParentCompositionContext(newRecomposer)
+ val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob())
+ recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job]
+ scope.launch {
+ newRecomposer.runRecomposeAndApplyChanges()
+ }
+ }
+ addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
+ }
+
+ override fun onDetachedFromWindow() {
+ recomposer?.cancel()
+ recomposerJob?.cancel()
+ super.onDetachedFromWindow()
+ }
+
+ fun setupView() {
+ Log.d(TAG, "setupView - isDismissable: $isDismissable")
+
+ composeView.setContent {
+ val session by Clerk.sessionFlow.collectAsStateWithLifecycle()
+
+ var hadSession by remember { mutableStateOf(Clerk.session != null) }
+
+ LaunchedEffect(session) {
+ if (hadSession && session == null) {
+ Log.d(TAG, "Sign-out detected")
+ sendEvent("signedOut", emptyMap())
+ }
+ if (session != null) {
+ hadSession = true
+ }
+ }
+
+ val content = @androidx.compose.runtime.Composable {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ UserProfileView(
+ clerkTheme = Clerk.customTheme,
+ onDismiss = {
+ Log.d(TAG, "Profile dismissed")
+ sendEvent("dismissed", emptyMap())
+ }
+ )
+ }
+ }
+ }
+
+ if (activity != null) {
+ CompositionLocalProvider(
+ LocalViewModelStoreOwner provides activity,
+ LocalLifecycleOwner provides activity,
+ LocalSavedStateRegistryOwner provides activity,
+ ) {
+ content()
+ }
+ } else {
+ content()
+ }
+ }
+ }
+
+ private fun sendEvent(type: String, data: Map) {
+ val reactContext = context as? ReactContext ?: return
+ val eventData = Arguments.createMap().apply {
+ putString("type", type)
+ val jsonString = try {
+ org.json.JSONObject(data).toString()
+ } catch (e: Exception) {
+ "{}"
+ }
+ putString("data", jsonString)
+ }
+ reactContext.getJSModule(RCTEventEmitter::class.java)
+ .receiveEvent(id, "onProfileEvent", eventData)
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt
new file mode 100644
index 00000000000..bc5a338271e
--- /dev/null
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt
@@ -0,0 +1,32 @@
+package expo.modules.clerk
+
+import com.facebook.react.common.MapBuilder
+import com.facebook.react.uimanager.SimpleViewManager
+import com.facebook.react.uimanager.ThemedReactContext
+import com.facebook.react.uimanager.annotations.ReactProp
+import com.facebook.react.viewmanagers.ClerkUserProfileViewManagerInterface
+
+class ClerkUserProfileViewManager : SimpleViewManager(),
+ ClerkUserProfileViewManagerInterface {
+
+ override fun getName(): String = "ClerkUserProfileView"
+
+ override fun createViewInstance(reactContext: ThemedReactContext): ClerkUserProfileNativeView {
+ return ClerkUserProfileNativeView(reactContext)
+ }
+
+ @ReactProp(name = "isDismissable")
+ override fun setIsDismissable(view: ClerkUserProfileNativeView, isDismissable: Boolean) {
+ view.isDismissable = isDismissable
+ view.setupView()
+ }
+
+ override fun getExportedCustomBubblingEventTypeConstants(): MutableMap? {
+ return MapBuilder.builder()
+ .put("onProfileEvent", MapBuilder.of(
+ "phasedRegistrationNames",
+ MapBuilder.of("bubbled", "onProfileEvent")
+ ))
+ .build() as MutableMap
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt
new file mode 100644
index 00000000000..e77ad21ddf0
--- /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.auth.signOut()
+ when (result) {
+ is ClerkResult.Success -> {
+ println("[ClerkViewFactory] Sign out successful")
+ }
+ is ClerkResult.Failure -> {
+ println("[ClerkViewFactory] Sign out failed: ${result.error}")
+ throw Exception("Sign out failed: ${result.error}")
+ }
+ }
+ }
+
+ override fun isInitialized(): Boolean {
+ return Clerk.isInitialized.value
+ }
+
+ companion object {
+ /**
+ * Initialize the ClerkViewFactory and register it globally.
+ * Call this from your Application.onCreate() or MainActivity.onCreate()
+ */
+ fun initialize() {
+ ClerkViewFactoryRegistry.factory = ClerkViewFactory()
+ println("[ClerkViewFactory] Factory registered")
+ }
+ }
+}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt
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/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt
index 3234fea2214..54183ce5552 100644
--- a/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt
@@ -9,225 +9,209 @@ import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.NoCredentialException
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactMethod
+import expo.modules.clerk.NativeClerkGoogleSignInSpec
+import com.facebook.react.bridge.ReadableMap
+import com.facebook.react.bridge.WritableNativeMap
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
-import expo.modules.kotlin.Promise
-import expo.modules.kotlin.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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
-// Configuration parameters
-class ConfigureParams : Record {
- @Field
- val webClientId: String = ""
+class ClerkGoogleSignInModule(reactContext: ReactApplicationContext) :
+ NativeClerkGoogleSignInSpec(reactContext) {
- @Field
- val hostedDomain: String? = null
-
- @Field
- val autoSelectEnabled: Boolean? = null
-}
-
-// Sign-in parameters
-class SignInParams : Record {
- @Field
- val nonce: String? = null
-
- @Field
- val filterByAuthorizedAccounts: Boolean? = null
-}
-
-// Create account parameters
-class CreateAccountParams : Record {
- @Field
- val nonce: String? = null
-}
-
-// Explicit sign-in parameters
-class ExplicitSignInParams : Record {
- @Field
- val nonce: String? = null
-}
-
-// Custom exceptions
-class GoogleSignInCancelledException : CodedException("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", null)
-class GoogleSignInNoCredentialException : CodedException("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", null)
-class GoogleSignInException(message: String) : CodedException("GOOGLE_SIGN_IN_ERROR", message, null)
-class GoogleSignInNotConfiguredException : CodedException("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", null)
-class GoogleSignInActivityUnavailableException : CodedException("E_ACTIVITY_UNAVAILABLE", "Activity is not available", null)
-
-class ClerkGoogleSignInModule : Module() {
private var webClientId: String? = null
private var hostedDomain: String? = null
private var autoSelectEnabled: Boolean = false
private val mainScope = CoroutineScope(Dispatchers.Main)
- private val context: Context
- get() = requireNotNull(appContext.reactContext)
-
private val credentialManager: CredentialManager
- get() = CredentialManager.create(context)
+ get() = CredentialManager.create(reactApplicationContext)
- override fun definition() = ModuleDefinition {
- Name("ClerkGoogleSignIn")
+ override fun getName(): String = "ClerkGoogleSignIn"
- // Configure the module
- Function("configure") { params: ConfigureParams ->
- webClientId = params.webClientId
- hostedDomain = params.hostedDomain
- autoSelectEnabled = params.autoSelectEnabled ?: false
- }
+ // MARK: - configure
- // Sign in - attempts automatic sign-in with saved credentials
- AsyncFunction("signIn") { params: SignInParams?, promise: Promise ->
- val clientId = webClientId ?: run {
- promise.reject(GoogleSignInNotConfiguredException())
- return@AsyncFunction
- }
+ @ReactMethod
+ override fun configure(params: ReadableMap) {
+ webClientId = if (params.hasKey("webClientId")) params.getString("webClientId") else null
+ hostedDomain = if (params.hasKey("hostedDomain")) params.getString("hostedDomain") else null
+ autoSelectEnabled = if (params.hasKey("autoSelectEnabled")) params.getBoolean("autoSelectEnabled") else false
+ }
- val activity = appContext.currentActivity ?: run {
- promise.reject(GoogleSignInActivityUnavailableException())
- return@AsyncFunction
- }
+ // MARK: - signIn
- mainScope.launch {
- try {
- val googleIdOption = GetGoogleIdOption.Builder()
- .setFilterByAuthorizedAccounts(params?.filterByAuthorizedAccounts ?: true)
- .setServerClientId(clientId)
- .setAutoSelectEnabled(autoSelectEnabled)
- .apply {
- params?.nonce?.let { setNonce(it) }
- }
- .build()
-
- val request = GetCredentialRequest.Builder()
- .addCredentialOption(googleIdOption)
- .build()
-
- val result = credentialManager.getCredential(
- request = request,
- context = activity
- )
-
- handleSignInResult(result, promise)
- } catch (e: GetCredentialCancellationException) {
- promise.reject(GoogleSignInCancelledException())
- } catch (e: NoCredentialException) {
- promise.reject(GoogleSignInNoCredentialException())
- } catch (e: GetCredentialException) {
- promise.reject(GoogleSignInException(e.message ?: "Unknown error"))
- } catch (e: Exception) {
- promise.reject(GoogleSignInException(e.message ?: "Unknown error"))
+ @ReactMethod
+ override fun signIn(params: ReadableMap?, promise: Promise) {
+ val clientId = webClientId ?: run {
+ promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.")
+ return
+ }
+
+ val activity = getCurrentActivity() ?: run {
+ promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available")
+ return
+ }
+
+ mainScope.launch {
+ try {
+ val filterByAuthorized = params?.let {
+ if (it.hasKey("filterByAuthorizedAccounts")) it.getBoolean("filterByAuthorizedAccounts") else true
+ } ?: true
+ val nonce = params?.let {
+ if (it.hasKey("nonce")) it.getString("nonce") else null
}
+
+ val googleIdOption = GetGoogleIdOption.Builder()
+ .setFilterByAuthorizedAccounts(filterByAuthorized)
+ .setServerClientId(clientId)
+ .setAutoSelectEnabled(autoSelectEnabled)
+ .apply {
+ nonce?.let { setNonce(it) }
+ }
+ .build()
+
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(googleIdOption)
+ .build()
+
+ val result = credentialManager.getCredential(
+ request = request,
+ context = activity
+ )
+
+ handleSignInResult(result, promise)
+ } catch (e: GetCredentialCancellationException) {
+ promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e)
+ } catch (e: NoCredentialException) {
+ promise.reject("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", e)
+ } catch (e: GetCredentialException) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e)
+ } catch (e: Exception) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e)
}
}
+ }
- // Create account - shows account creation UI
- AsyncFunction("createAccount") { params: CreateAccountParams?, promise: Promise ->
- val clientId = webClientId ?: run {
- promise.reject(GoogleSignInNotConfiguredException())
- return@AsyncFunction
- }
+ // MARK: - createAccount
- val activity = appContext.currentActivity ?: run {
- promise.reject(GoogleSignInActivityUnavailableException())
- return@AsyncFunction
- }
+ @ReactMethod
+ override fun createAccount(params: ReadableMap?, promise: Promise) {
+ val clientId = webClientId ?: run {
+ promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.")
+ return
+ }
- mainScope.launch {
- try {
- val googleIdOption = GetGoogleIdOption.Builder()
- .setFilterByAuthorizedAccounts(false) // Show all accounts for creation
- .setServerClientId(clientId)
- .apply {
- params?.nonce?.let { setNonce(it) }
- }
- .build()
-
- val request = GetCredentialRequest.Builder()
- .addCredentialOption(googleIdOption)
- .build()
-
- val result = credentialManager.getCredential(
- request = request,
- context = activity
- )
-
- handleSignInResult(result, promise)
- } catch (e: GetCredentialCancellationException) {
- promise.reject(GoogleSignInCancelledException())
- } catch (e: NoCredentialException) {
- promise.reject(GoogleSignInNoCredentialException())
- } catch (e: GetCredentialException) {
- promise.reject(GoogleSignInException(e.message ?: "Unknown error"))
- } catch (e: Exception) {
- promise.reject(GoogleSignInException(e.message ?: "Unknown error"))
+ val activity = getCurrentActivity() ?: run {
+ promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available")
+ return
+ }
+
+ mainScope.launch {
+ try {
+ val nonce = params?.let {
+ if (it.hasKey("nonce")) it.getString("nonce") else null
}
+
+ val googleIdOption = GetGoogleIdOption.Builder()
+ .setFilterByAuthorizedAccounts(false) // Show all accounts for creation
+ .setServerClientId(clientId)
+ .apply {
+ nonce?.let { setNonce(it) }
+ }
+ .build()
+
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(googleIdOption)
+ .build()
+
+ val result = credentialManager.getCredential(
+ request = request,
+ context = activity
+ )
+
+ handleSignInResult(result, promise)
+ } catch (e: GetCredentialCancellationException) {
+ promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e)
+ } catch (e: NoCredentialException) {
+ promise.reject("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", e)
+ } catch (e: GetCredentialException) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e)
+ } catch (e: Exception) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e)
}
}
+ }
- // Explicit sign-in - uses Sign In With Google button flow
- AsyncFunction("presentExplicitSignIn") { params: ExplicitSignInParams?, promise: Promise ->
- val clientId = webClientId ?: run {
- promise.reject(GoogleSignInNotConfiguredException())
- return@AsyncFunction
- }
+ // MARK: - presentExplicitSignIn
- val activity = appContext.currentActivity ?: run {
- promise.reject(GoogleSignInActivityUnavailableException())
- return@AsyncFunction
- }
+ @ReactMethod
+ override fun presentExplicitSignIn(params: ReadableMap?, promise: Promise) {
+ val clientId = webClientId ?: run {
+ promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.")
+ return
+ }
- mainScope.launch {
- try {
- val signInWithGoogleOption = GetSignInWithGoogleOption.Builder(clientId)
- .apply {
- params?.nonce?.let { setNonce(it) }
- hostedDomain?.let { setHostedDomainFilter(it) }
- }
- .build()
-
- val request = GetCredentialRequest.Builder()
- .addCredentialOption(signInWithGoogleOption)
- .build()
-
- val result = credentialManager.getCredential(
- request = request,
- context = activity
- )
-
- handleSignInResult(result, promise)
- } catch (e: GetCredentialCancellationException) {
- promise.reject(GoogleSignInCancelledException())
- } catch (e: GetCredentialException) {
- promise.reject(GoogleSignInException(e.message ?: "Unknown error"))
- } catch (e: Exception) {
- promise.reject(GoogleSignInException(e.message ?: "Unknown error"))
+ val activity = getCurrentActivity() ?: run {
+ promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available")
+ return
+ }
+
+ mainScope.launch {
+ try {
+ val nonce = params?.let {
+ if (it.hasKey("nonce")) it.getString("nonce") else null
}
+
+ val signInWithGoogleOption = GetSignInWithGoogleOption.Builder(clientId)
+ .apply {
+ nonce?.let { setNonce(it) }
+ hostedDomain?.let { setHostedDomainFilter(it) }
+ }
+ .build()
+
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(signInWithGoogleOption)
+ .build()
+
+ val result = credentialManager.getCredential(
+ request = request,
+ context = activity
+ )
+
+ handleSignInResult(result, promise)
+ } catch (e: GetCredentialCancellationException) {
+ promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e)
+ } catch (e: GetCredentialException) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e)
+ } catch (e: Exception) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e)
}
}
+ }
- // Sign out - clears credential state
- AsyncFunction("signOut") { promise: Promise ->
- mainScope.launch {
- try {
- credentialManager.clearCredentialState(ClearCredentialStateRequest())
- promise.resolve(null)
- } catch (e: Exception) {
- promise.reject(GoogleSignInException(e.message ?: "Failed to sign out"))
- }
+ // MARK: - signOut
+
+ @ReactMethod
+ override fun signOut(promise: Promise) {
+ mainScope.launch {
+ try {
+ credentialManager.clearCredentialState(ClearCredentialStateRequest())
+ promise.resolve(null)
+ } catch (e: Exception) {
+ promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Failed to sign out", e)
}
}
}
+ // MARK: - Helpers
+
private fun handleSignInResult(result: GetCredentialResponse, promise: Promise) {
when (val credential = result.credential) {
is CustomCredential -> {
@@ -235,29 +219,35 @@ class ClerkGoogleSignInModule : Module() {
try {
val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data)
- promise.resolve(mapOf(
- "type" to "success",
- "data" to mapOf(
- "idToken" to googleIdTokenCredential.idToken,
- "user" to mapOf(
- "id" to googleIdTokenCredential.id,
- "email" to googleIdTokenCredential.id,
- "name" to googleIdTokenCredential.displayName,
- "givenName" to googleIdTokenCredential.givenName,
- "familyName" to googleIdTokenCredential.familyName,
- "photo" to googleIdTokenCredential.profilePictureUri?.toString()
- )
- )
- ))
+ val userMap = WritableNativeMap().apply {
+ putString("id", googleIdTokenCredential.id)
+ putString("email", googleIdTokenCredential.id)
+ putString("name", googleIdTokenCredential.displayName)
+ putString("givenName", googleIdTokenCredential.givenName)
+ putString("familyName", googleIdTokenCredential.familyName)
+ putString("photo", googleIdTokenCredential.profilePictureUri?.toString())
+ }
+
+ val dataMap = WritableNativeMap().apply {
+ putString("idToken", googleIdTokenCredential.idToken)
+ putMap("user", userMap)
+ }
+
+ val responseMap = WritableNativeMap().apply {
+ putString("type", "success")
+ putMap("data", dataMap)
+ }
+
+ promise.resolve(responseMap)
} catch (e: GoogleIdTokenParsingException) {
- promise.reject(GoogleSignInException("Failed to parse Google ID token: ${e.message}"))
+ promise.reject("GOOGLE_SIGN_IN_ERROR", "Failed to parse Google ID token: ${e.message}", e)
}
} else {
- promise.reject(GoogleSignInException("Unexpected credential type: ${credential.type}"))
+ promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type: ${credential.type}")
}
}
else -> {
- promise.reject(GoogleSignInException("Unexpected credential type"))
+ promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type")
}
}
}
diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js
index 65835131de7..f8dca293ce2 100644
--- a/packages/expo/app.plugin.js
+++ b/packages/expo/app.plugin.js
@@ -1 +1,624 @@
-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. iOS is configured with Swift Package Manager dependency for clerk-ios
+ * 2. Android is configured with packaging exclusions for dependencies
+ *
+ * Native modules are registered via react-native.config.js and standard
+ * React Native autolinking (RCTViewManager / ReactPackage).
+ */
+const {
+ withXcodeProject,
+ withDangerousMod,
+ withInfoPlist,
+ withAppBuildGradle,
+ withEntitlementsPlist,
+} = 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 = '1.0.0';
+
+const CLERK_MIN_IOS_VERSION = '17.0';
+
+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: 'exactVersion',
+ version: CLERK_IOS_VERSION,
+ },
+ };
+
+ // Add package product dependencies (ClerkKit + ClerkKitUI)
+ const productUuidKit = xcodeProject.generateUuid();
+ const productUuidKitUI = xcodeProject.generateUuid();
+ if (!xcodeProject.hash.project.objects.XCSwiftPackageProductDependency) {
+ xcodeProject.hash.project.objects.XCSwiftPackageProductDependency = {};
+ }
+
+ xcodeProject.hash.project.objects.XCSwiftPackageProductDependency[productUuidKit] = {
+ isa: 'XCSwiftPackageProductDependency',
+ package: packageUuid,
+ productName: 'ClerkKit',
+ };
+
+ xcodeProject.hash.project.objects.XCSwiftPackageProductDependency[productUuidKitUI] = {
+ isa: 'XCSwiftPackageProductDependency',
+ package: packageUuid,
+ productName: 'ClerkKitUI',
+ };
+
+ // 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 products to main app target
+ const nativeTarget = xcodeProject.hash.project.objects.PBXNativeTarget[targetUuid];
+ if (!nativeTarget.packageProductDependencies) {
+ nativeTarget.packageProductDependencies = [];
+ }
+
+ const kitAlreadyAdded = nativeTarget.packageProductDependencies.some(dep => dep.value === productUuidKit);
+ if (!kitAlreadyAdded) {
+ nativeTarget.packageProductDependencies.push({
+ value: productUuidKit,
+ comment: 'ClerkKit',
+ });
+ }
+
+ const kitUIAlreadyAdded = nativeTarget.packageProductDependencies.some(dep => dep.value === productUuidKitUI);
+ if (!kitUIAlreadyAdded) {
+ nativeTarget.packageProductDependencies.push({
+ value: productUuidKitUI,
+ comment: 'ClerkKitUI',
+ });
+ }
+
+ // Also add packages 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 podKitAdded = target.packageProductDependencies.some(dep => dep.value === productUuidKit);
+ if (!podKitAdded) {
+ target.packageProductDependencies.push({
+ value: productUuidKit,
+ comment: 'ClerkKit',
+ });
+ }
+
+ const podKitUIAdded = target.packageProductDependencies.some(dep => dep.value === productUuidKitUI);
+ if (!podKitUIAdded) {
+ target.packageProductDependencies.push({
+ value: productUuidKitUI,
+ comment: 'ClerkKitUI',
+ });
+ }
+
+ console.log(`✅ Added ClerkKit and ClerkKitUI packages 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 ClerkKit` 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;
+
+ // --- META-INF exclusion ---
+ if (!buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) {
+ // AGP 8+ uses `packaging` DSL, older versions use `packagingOptions`
+ const packagingMatch = buildGradle.match(/packaging\s*\{/) || buildGradle.match(/packagingOptions\s*\{/);
+ if (packagingMatch) {
+ const blockName = packagingMatch[0].trim().replace(/\s*\{$/, '');
+ const resourcesExclude = `${blockName} {
+ // Clerk Android SDK: exclude duplicate META-INF files
+ resources {
+ excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
+ }`;
+
+ buildGradle = buildGradle.replace(new RegExp(`${blockName}\\s*\\{`), resourcesExclude);
+ } else {
+ // No packaging block found; append one at the end of the android block
+ const androidBlockEnd = buildGradle.lastIndexOf('}');
+ if (androidBlockEnd !== -1) {
+ const packagingBlock = `\n packaging {\n resources {\n excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']\n }\n }\n`;
+ buildGradle = buildGradle.slice(0, androidBlockEnd) + packagingBlock + buildGradle.slice(androidBlockEnd);
+ }
+ }
+ console.log('✅ Clerk Android packaging exclusions added');
+ }
+
+ // --- Kotlin metadata version check skip ---
+ if (!buildGradle.includes('-Xskip-metadata-version-check')) {
+ const kotlinOptionsMatch = buildGradle.match(/kotlinOptions\s*\{/);
+ if (kotlinOptionsMatch) {
+ buildGradle = buildGradle.replace(
+ /kotlinOptions\s*\{/,
+ `kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']`,
+ );
+ } else {
+ const androidMatch = buildGradle.match(/android\s*\{/);
+ if (androidMatch) {
+ buildGradle = buildGradle.replace(
+ /android\s*\{/,
+ `android {\n kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']\n }`,
+ );
+ }
+ }
+ console.log('✅ Clerk Android Kotlin metadata version check skip added');
+ }
+
+ modConfig.modResults.contents = buildGradle;
+ 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. iOS gets Swift Package Manager dependency for clerk-ios SDK
+ * 2. Android gets packaging exclusions for dependency conflicts
+ * 3. Google Sign-In URL scheme is configured (if env var is set)
+ *
+ * Native modules are registered via react-native.config.js and standard
+ * React Native autolinking (RCTViewManager / ReactPackage).
+ */
+/**
+ * Write ClerkKeychainService to Info.plist when keychainService is provided.
+ * This allows extension apps (watch, widget, app clip) to share the same
+ * keychain entry as the main app by using a custom service identifier.
+ */
+const withClerkKeychainService = (config, { keychainService } = {}) => {
+ if (!keychainService) {
+ return config;
+ }
+
+ return withInfoPlist(config, modConfig => {
+ modConfig.modResults.ClerkKeychainService = keychainService;
+ console.log(`✅ Set ClerkKeychainService in Info.plist: ${keychainService}`);
+ return modConfig;
+ });
+};
+
+/**
+ * Add Sign in with Apple entitlement to the iOS app.
+ * Required for the native Apple Sign In flow via ASAuthorizationController.
+ */
+const withClerkAppleSignIn = config => {
+ return withEntitlementsPlist(config, modConfig => {
+ if (!modConfig.modResults['com.apple.developer.applesignin']) {
+ modConfig.modResults['com.apple.developer.applesignin'] = ['Default'];
+ console.log('✅ Added Sign in with Apple entitlement');
+ }
+ return modConfig;
+ });
+};
+
+const withClerkExpo = (config, props = {}) => {
+ config = withClerkIOS(config);
+ config = withClerkAppleSignIn(config);
+ config = withClerkGoogleSignIn(config);
+ config = withClerkAndroid(config);
+ config = withClerkKeychainService(config, props);
+ return config;
+};
+
+module.exports = withClerkExpo;
diff --git a/packages/expo/expo-module.config.json b/packages/expo/expo-module.config.json
index e59f14eef13..876f466b1ad 100644
--- a/packages/expo/expo-module.config.json
+++ b/packages/expo/expo-module.config.json
@@ -1,9 +1,3 @@
{
- "platforms": ["android", "ios"],
- "android": {
- "modules": ["expo.modules.clerk.googlesignin.ClerkGoogleSignInModule"]
- },
- "ios": {
- "modules": ["ClerkGoogleSignInModule"]
- }
+ "platforms": ["apple"]
}
diff --git a/packages/expo/ios/ClerkAuthViewManager.m b/packages/expo/ios/ClerkAuthViewManager.m
new file mode 100644
index 00000000000..c5a25dd8a9b
--- /dev/null
+++ b/packages/expo/ios/ClerkAuthViewManager.m
@@ -0,0 +1,9 @@
+#import
+
+@interface RCT_EXTERN_MODULE(ClerkAuthViewManager, RCTViewManager)
+
+RCT_EXPORT_VIEW_PROPERTY(mode, NSString)
+RCT_EXPORT_VIEW_PROPERTY(isDismissable, NSNumber)
+RCT_EXPORT_VIEW_PROPERTY(onAuthEvent, RCTBubblingEventBlock)
+
+@end
diff --git a/packages/expo/ios/ClerkAuthViewManager.swift b/packages/expo/ios/ClerkAuthViewManager.swift
new file mode 100644
index 00000000000..0ab9629edba
--- /dev/null
+++ b/packages/expo/ios/ClerkAuthViewManager.swift
@@ -0,0 +1,13 @@
+import React
+
+@objc(ClerkAuthViewManager)
+class ClerkAuthViewManager: RCTViewManager {
+
+ override static func requiresMainQueueSetup() -> Bool {
+ return true
+ }
+
+ override func view() -> UIView! {
+ return ClerkAuthNativeView()
+ }
+}
diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec
new file mode 100644
index 00000000000..fbd91f9a91c
--- /dev/null
+++ b/packages/expo/ios/ClerkExpo.podspec
@@ -0,0 +1,46 @@
+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' => '0.0.0-FALLBACK',
+ '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']
+ # NOTE: Must match CLERK_MIN_IOS_VERSION in app.plugin.js
+ 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.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
+ }
+
+ # Only include the module files in the pod (both Swift and ObjC bridges).
+ # ClerkViewFactory.swift (with views) is injected into the app target by the config plugin
+ # because it uses `import ClerkKit` which is only available via SPM in the app target.
+ s.source_files = "ClerkExpoModule.swift", "ClerkExpoModule.m",
+ "ClerkAuthViewManager.swift", "ClerkAuthViewManager.m",
+ "ClerkUserProfileViewManager.swift", "ClerkUserProfileViewManager.m"
+
+ install_modules_dependencies(s)
+end
diff --git a/packages/expo/ios/ClerkExpoModule.m b/packages/expo/ios/ClerkExpoModule.m
new file mode 100644
index 00000000000..febfe003c61
--- /dev/null
+++ b/packages/expo/ios/ClerkExpoModule.m
@@ -0,0 +1,28 @@
+#import
+#import
+
+@interface RCT_EXTERN_MODULE(ClerkExpo, RCTEventEmitter)
+
+RCT_EXTERN_METHOD(configure:(NSString *)publishableKey
+ bearerToken:(NSString *)bearerToken
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(presentAuth:(NSDictionary *)options
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(presentUserProfile:(NSDictionary *)options
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(getSession:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(getClientToken:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(signOut:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+@end
diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift
new file mode 100644
index 00000000000..eabfb44d685
--- /dev/null
+++ b/packages/expo/ios/ClerkExpoModule.swift
@@ -0,0 +1,395 @@
+// ClerkExpoModule - Native module for Clerk integration
+// This module provides the configure function and view presentation methods.
+// Views are presented as modal view controllers (not embedded views)
+// because the Clerk SDK (SPM) isn't accessible from CocoaPods.
+
+import UIKit
+import React
+
+// 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 — 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, bearerToken: String?) async throws
+ func getSession() async -> [String: Any]?
+ func signOut() async throws
+}
+
+// MARK: - Module
+
+@objc(ClerkExpo)
+class ClerkExpoModule: RCTEventEmitter {
+
+ private static var _hasListeners = false
+
+ override init() {
+ super.init()
+ }
+
+ @objc override static func requiresMainQueueSetup() -> Bool {
+ return false
+ }
+
+ override func supportedEvents() -> [String]! {
+ return ["onAuthStateChange"]
+ }
+
+ override func startObserving() {
+ ClerkExpoModule._hasListeners = true
+ }
+
+ override func stopObserving() {
+ ClerkExpoModule._hasListeners = false
+ }
+
+ /// Returns the topmost presented view controller, avoiding deprecated `keyWindow`.
+ private static func topViewController() -> UIViewController? {
+ guard let scene = UIApplication.shared.connectedScenes
+ .compactMap({ $0 as? UIWindowScene })
+ .first(where: { $0.activationState == .foregroundActive }),
+ let rootVC = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController
+ else { return nil }
+
+ var top = rootVC
+ while let presented = top.presentedViewController {
+ top = presented
+ }
+ return top
+ }
+
+ // MARK: - configure
+
+ @objc func configure(_ publishableKey: String,
+ bearerToken: String?,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard let factory = clerkViewFactory else {
+ reject("E_NOT_INITIALIZED", "Clerk not initialized. Make sure ClerkViewFactory is registered.", nil)
+ return
+ }
+
+ Task {
+ do {
+ try await factory.configure(publishableKey: publishableKey, bearerToken: bearerToken)
+ resolve(nil)
+ } catch {
+ reject("E_CONFIGURE_FAILED", error.localizedDescription, error)
+ }
+ }
+ }
+
+ // MARK: - presentAuth
+
+ @objc func presentAuth(_ options: NSDictionary,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard let factory = clerkViewFactory else {
+ reject("E_NOT_INITIALIZED", "Clerk not initialized", nil)
+ return
+ }
+
+ let mode = options["mode"] as? String ?? "signInOrUp"
+ let dismissable = options["dismissable"] as? Bool ?? true
+
+ DispatchQueue.main.async {
+ guard let vc = factory.createAuthViewController(mode: mode, dismissable: dismissable, completion: { result in
+ switch result {
+ case .success(let data):
+ resolve(data)
+ case .failure(let error):
+ reject("E_AUTH_FAILED", error.localizedDescription, error)
+ }
+ }) else {
+ reject("E_CREATE_FAILED", "Could not create auth view controller", nil)
+ return
+ }
+
+ if let rootVC = Self.topViewController() {
+ rootVC.present(vc, animated: true)
+ } else {
+ reject("E_NO_ROOT_VC", "No root view controller available to present auth", nil)
+ }
+ }
+ }
+
+ // MARK: - presentUserProfile
+
+ @objc func presentUserProfile(_ options: NSDictionary,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard let factory = clerkViewFactory else {
+ reject("E_NOT_INITIALIZED", "Clerk not initialized", nil)
+ return
+ }
+
+ let dismissable = options["dismissable"] as? Bool ?? true
+
+ DispatchQueue.main.async {
+ guard let vc = factory.createUserProfileViewController(dismissable: dismissable, completion: { result in
+ switch result {
+ case .success(let data):
+ resolve(data)
+ case .failure(let error):
+ reject("E_PROFILE_FAILED", error.localizedDescription, error)
+ }
+ }) else {
+ reject("E_CREATE_FAILED", "Could not create profile view controller", nil)
+ return
+ }
+
+ if let rootVC = Self.topViewController() {
+ rootVC.present(vc, animated: true)
+ } else {
+ reject("E_NO_ROOT_VC", "No root view controller available to present profile", nil)
+ }
+ }
+ }
+
+ // MARK: - getSession
+
+ @objc func getSession(_ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard let factory = clerkViewFactory else {
+ resolve(nil)
+ return
+ }
+
+ Task {
+ let session = await factory.getSession()
+ resolve(session)
+ }
+ }
+
+ // MARK: - getClientToken
+
+ @objc func getClientToken(_ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ // Use a custom keychain service if configured in Info.plist (for extension apps
+ // sharing a keychain group). Falls back to the main bundle identifier.
+ let keychainService: String = {
+ if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty {
+ return custom
+ }
+ return Bundle.main.bundleIdentifier ?? ""
+ }()
+
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: keychainService,
+ 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 {
+ resolve(String(data: data, encoding: .utf8))
+ } else {
+ resolve(nil)
+ }
+ }
+
+ // MARK: - signOut
+
+ @objc func signOut(_ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard let factory = clerkViewFactory else {
+ reject("E_NOT_INITIALIZED", "Clerk not initialized", nil)
+ return
+ }
+
+ Task {
+ do {
+ try await factory.signOut()
+ resolve(nil)
+ } catch {
+ reject("E_SIGN_OUT_FAILED", error.localizedDescription, error)
+ }
+ }
+ }
+}
+
+// MARK: - Inline View: ClerkAuthNativeView
+
+public class ClerkAuthNativeView: UIView {
+ private var hostingController: UIViewController?
+ private var currentMode: String = "signInOrUp"
+ private var currentDismissable: Bool = true
+ private var hasInitialized: Bool = false
+
+ @objc var onAuthEvent: RCTBubblingEventBlock?
+
+ @objc var mode: NSString? {
+ didSet {
+ currentMode = (mode as String?) ?? "signInOrUp"
+ if hasInitialized { updateView() }
+ }
+ }
+
+ @objc var isDismissable: NSNumber? {
+ didSet {
+ currentDismissable = isDismissable?.boolValue ?? true
+ if hasInitialized { updateView() }
+ }
+ }
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override public func didMoveToWindow() {
+ super.didMoveToWindow()
+ if window != nil && !hasInitialized {
+ hasInitialized = true
+ updateView()
+ }
+ }
+
+ private func updateView() {
+ // Remove old hosting controller
+ hostingController?.view.removeFromSuperview()
+ hostingController?.removeFromParent()
+ hostingController = nil
+
+ guard let factory = clerkViewFactory else { return }
+
+ guard let returnedController = factory.createAuthView(
+ mode: currentMode,
+ dismissable: currentDismissable,
+ onEvent: { [weak self] eventName, data in
+ // Convert data dict to JSON string for codegen event
+ let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data()
+ let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
+ self?.onAuthEvent?(["type": eventName, "data": jsonString])
+ }
+ ) 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
+ }
+
+ override public func layoutSubviews() {
+ super.layoutSubviews()
+ hostingController?.view.frame = bounds
+ }
+}
+
+// MARK: - Inline View: ClerkUserProfileNativeView
+
+public class ClerkUserProfileNativeView: UIView {
+ private var hostingController: UIViewController?
+ private var currentDismissable: Bool = true
+ private var hasInitialized: Bool = false
+
+ @objc var onProfileEvent: RCTBubblingEventBlock?
+
+ @objc var isDismissable: NSNumber? {
+ didSet {
+ currentDismissable = isDismissable?.boolValue ?? true
+ if hasInitialized { updateView() }
+ }
+ }
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override public func didMoveToWindow() {
+ super.didMoveToWindow()
+ if window != nil && !hasInitialized {
+ hasInitialized = true
+ updateView()
+ }
+ }
+
+ private func updateView() {
+ // Remove old hosting controller
+ hostingController?.view.removeFromSuperview()
+ hostingController?.removeFromParent()
+ hostingController = nil
+
+ guard let factory = clerkViewFactory else { return }
+
+ guard let returnedController = factory.createUserProfileView(
+ dismissable: currentDismissable,
+ onEvent: { [weak self] eventName, data in
+ let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data()
+ let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
+ self?.onProfileEvent?(["type": eventName, "data": jsonString])
+ }
+ ) else { return }
+
+ if let parentVC = findViewController() {
+ parentVC.addChild(returnedController)
+ returnedController.view.frame = bounds
+ returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+ addSubview(returnedController.view)
+ returnedController.didMove(toParent: parentVC)
+ hostingController = returnedController
+ } else {
+ returnedController.view.frame = bounds
+ returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+ addSubview(returnedController.view)
+ hostingController = returnedController
+ }
+ }
+
+ private func findViewController() -> UIViewController? {
+ var responder: UIResponder? = self
+ while let nextResponder = responder?.next {
+ if let vc = nextResponder as? UIViewController {
+ return vc
+ }
+ responder = nextResponder
+ }
+ return nil
+ }
+
+ override public func layoutSubviews() {
+ super.layoutSubviews()
+ hostingController?.view.frame = bounds
+ }
+}
diff --git a/packages/expo/ios/ClerkGoogleSignIn.podspec b/packages/expo/ios/ClerkGoogleSignIn.podspec
index be0f3551b2b..e356ea70c8c 100644
--- a/packages/expo/ios/ClerkGoogleSignIn.podspec
+++ b/packages/expo/ios/ClerkGoogleSignIn.podspec
@@ -15,8 +15,10 @@ Pod::Spec.new do |s|
s.source = { :git => 'https://github.com/clerk/javascript.git' }
s.static_framework = true
- s.dependency 'ExpoModulesCore'
s.dependency 'GoogleSignIn', '~> 9.0'
- s.source_files = '*.swift'
+ # Only include the Google Sign-In module files
+ s.source_files = 'ClerkGoogleSignInModule.swift', 'ClerkGoogleSignInModule.m'
+
+ install_modules_dependencies(s)
end
diff --git a/packages/expo/ios/ClerkGoogleSignInModule.m b/packages/expo/ios/ClerkGoogleSignInModule.m
new file mode 100644
index 00000000000..5848d4a17b7
--- /dev/null
+++ b/packages/expo/ios/ClerkGoogleSignInModule.m
@@ -0,0 +1,22 @@
+#import
+
+@interface RCT_EXTERN_MODULE(ClerkGoogleSignIn, NSObject)
+
+RCT_EXTERN_METHOD(configure:(NSDictionary *)params)
+
+RCT_EXTERN_METHOD(signIn:(NSDictionary *)params
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(createAccount:(NSDictionary *)params
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(presentExplicitSignIn:(NSDictionary *)params
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(signOut:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+
+@end
diff --git a/packages/expo/ios/ClerkGoogleSignInModule.swift b/packages/expo/ios/ClerkGoogleSignInModule.swift
index c06f85b8031..ea29ad2ae79 100644
--- a/packages/expo/ios/ClerkGoogleSignInModule.swift
+++ b/packages/expo/ios/ClerkGoogleSignInModule.swift
@@ -1,229 +1,192 @@
-import ExpoModulesCore
+import React
import GoogleSignIn
-public class ClerkGoogleSignInModule: Module {
- private var clientId: String?
- private var hostedDomain: String?
-
- public func definition() -> ModuleDefinition {
- Name("ClerkGoogleSignIn")
-
- // Configure the module
- Function("configure") { (params: ConfigureParams) in
- self.clientId = params.iosClientId ?? params.webClientId
- self.hostedDomain = params.hostedDomain
-
- // Set the configuration globally
- // clientID: iOS client ID for OAuth flow
- // serverClientID: Web client ID for token audience (what Clerk backend verifies)
- if let clientId = self.clientId {
- let config = GIDConfiguration(
- clientID: clientId,
- serverClientID: params.webClientId
- )
- GIDSignIn.sharedInstance.configuration = config
- }
- }
-
- // Sign in - attempts sign-in with hint if available
- AsyncFunction("signIn") { (params: SignInParams?, promise: Promise) in
- guard self.clientId != nil else {
- promise.reject(NotConfiguredException())
- return
- }
-
- DispatchQueue.main.async {
- guard let presentingVC = self.getPresentingViewController() else {
- promise.reject(GoogleSignInException(message: "No presenting view controller available"))
- return
- }
-
- // Build sign-in hint if filtering by authorized accounts
- let hint: String? = params?.filterByAuthorizedAccounts == true
- ? GIDSignIn.sharedInstance.currentUser?.profile?.email
- : nil
-
- GIDSignIn.sharedInstance.signIn(
- withPresenting: presentingVC,
- hint: hint,
- additionalScopes: nil,
- nonce: params?.nonce
- ) { result, error in
- self.handleSignInResult(result: result, error: error, promise: promise)
- }
- }
- }
-
- // Create account - shows account creation UI (same as sign in on iOS)
- AsyncFunction("createAccount") { (params: CreateAccountParams?, promise: Promise) in
- guard self.clientId != nil else {
- promise.reject(NotConfiguredException())
- return
- }
-
- DispatchQueue.main.async {
- guard let presentingVC = self.getPresentingViewController() else {
- promise.reject(GoogleSignInException(message: "No presenting view controller available"))
- return
- }
-
- GIDSignIn.sharedInstance.signIn(
- withPresenting: presentingVC,
- hint: nil,
- additionalScopes: nil,
- nonce: params?.nonce
- ) { result, error in
- self.handleSignInResult(result: result, error: error, promise: promise)
- }
- }
- }
-
- // Explicit sign-in - uses standard Google Sign-In flow
- AsyncFunction("presentExplicitSignIn") { (params: ExplicitSignInParams?, promise: Promise) in
- guard self.clientId != nil else {
- promise.reject(NotConfiguredException())
- return
- }
-
- DispatchQueue.main.async {
- guard let presentingVC = self.getPresentingViewController() else {
- promise.reject(GoogleSignInException(message: "No presenting view controller available"))
- return
- }
-
- GIDSignIn.sharedInstance.signIn(
- withPresenting: presentingVC,
- hint: nil,
- additionalScopes: nil,
- nonce: params?.nonce
- ) { result, error in
- self.handleSignInResult(result: result, error: error, promise: promise)
- }
- }
- }
-
- // Sign out - clears credential state
- AsyncFunction("signOut") { (promise: Promise) in
- GIDSignIn.sharedInstance.signOut()
- promise.resolve(nil)
- }
- }
+@objc(ClerkGoogleSignIn)
+class ClerkGoogleSignInModule: NSObject, RCTBridgeModule {
- private func getPresentingViewController() -> UIViewController? {
- guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
- let window = scene.windows.first,
- let rootVC = window.rootViewController else {
- return nil
- }
-
- var topVC = rootVC
- while let presentedVC = topVC.presentedViewController {
- topVC = presentedVC
- }
- return topVC
- }
+ static func moduleName() -> String! {
+ return "ClerkGoogleSignIn"
+ }
+
+ @objc static func requiresMainQueueSetup() -> Bool {
+ return false
+ }
+
+ private var clientId: String?
+ private var hostedDomain: String?
+
+ // MARK: - configure
+
+ @objc func configure(_ params: NSDictionary) {
+ let webClientId = params["webClientId"] as? String ?? ""
+ let iosClientId = params["iosClientId"] as? String
+ self.clientId = iosClientId ?? webClientId
+ self.hostedDomain = params["hostedDomain"] as? String
- private func handleSignInResult(result: GIDSignInResult?, error: Error?, promise: Promise) {
- if let error = error {
- let nsError = error as NSError
-
- // Check for user cancellation
- if nsError.domain == kGIDSignInErrorDomain && nsError.code == GIDSignInError.canceled.rawValue {
- promise.reject(SignInCancelledException())
- return
- }
-
- promise.reject(GoogleSignInException(message: error.localizedDescription))
- return
- }
-
- guard let result = result,
- let idToken = result.user.idToken?.tokenString else {
- promise.reject(GoogleSignInException(message: "No ID token received"))
- return
- }
-
- let user = result.user
- let profile = user.profile
-
- let response: [String: Any] = [
- "type": "success",
- "data": [
- "idToken": idToken,
- "user": [
- "id": user.userID ?? "",
- "email": profile?.email ?? "",
- "name": profile?.name ?? "",
- "givenName": profile?.givenName ?? "",
- "familyName": profile?.familyName ?? "",
- "photo": profile?.imageURL(withDimension: 200)?.absoluteString ?? NSNull()
- ] as [String: Any]
- ] as [String: Any]
- ]
-
- promise.resolve(response)
+ if let clientId = self.clientId {
+ let config = GIDConfiguration(
+ clientID: clientId,
+ serverClientID: webClientId
+ )
+ GIDSignIn.sharedInstance.configuration = config
}
-}
+ }
-// MARK: - Records
+ // MARK: - signIn
-struct ConfigureParams: Record {
- @Field
- var webClientId: String = ""
+ @objc func signIn(_ params: NSDictionary?,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard self.clientId != nil else {
+ reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", nil)
+ return
+ }
- @Field
- var iosClientId: String?
+ DispatchQueue.main.async {
+ guard let presentingVC = self.getPresentingViewController() else {
+ reject("GOOGLE_SIGN_IN_ERROR", "No presenting view controller available", nil)
+ return
+ }
+
+ let filterByAuthorized = params?["filterByAuthorizedAccounts"] as? Bool ?? false
+ let hint: String? = filterByAuthorized
+ ? GIDSignIn.sharedInstance.currentUser?.profile?.email
+ : nil
+ let nonce = params?["nonce"] as? String
+
+ GIDSignIn.sharedInstance.signIn(
+ withPresenting: presentingVC,
+ hint: hint,
+ additionalScopes: nil,
+ nonce: nonce
+ ) { result, error in
+ self.handleSignInResult(result: result, error: error, resolve: resolve, reject: reject)
+ }
+ }
+ }
- @Field
- var hostedDomain: String?
+ // MARK: - createAccount
- @Field
- var autoSelectEnabled: Bool?
-}
+ @objc func createAccount(_ params: NSDictionary?,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard self.clientId != nil else {
+ reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", nil)
+ return
+ }
-struct SignInParams: Record {
- @Field
- var nonce: String?
+ DispatchQueue.main.async {
+ guard let presentingVC = self.getPresentingViewController() else {
+ reject("GOOGLE_SIGN_IN_ERROR", "No presenting view controller available", nil)
+ return
+ }
+
+ let nonce = params?["nonce"] as? String
+
+ GIDSignIn.sharedInstance.signIn(
+ withPresenting: presentingVC,
+ hint: nil,
+ additionalScopes: nil,
+ nonce: nonce
+ ) { result, error in
+ self.handleSignInResult(result: result, error: error, resolve: resolve, reject: reject)
+ }
+ }
+ }
- @Field
- var filterByAuthorizedAccounts: Bool?
-}
+ // MARK: - presentExplicitSignIn
-struct CreateAccountParams: Record {
- @Field
- var nonce: String?
-}
+ @objc func presentExplicitSignIn(_ params: NSDictionary?,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ guard self.clientId != nil else {
+ reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", nil)
+ return
+ }
-struct ExplicitSignInParams: Record {
- @Field
- var nonce: String?
-}
+ DispatchQueue.main.async {
+ guard let presentingVC = self.getPresentingViewController() else {
+ reject("GOOGLE_SIGN_IN_ERROR", "No presenting view controller available", nil)
+ return
+ }
+
+ let nonce = params?["nonce"] as? String
+
+ GIDSignIn.sharedInstance.signIn(
+ withPresenting: presentingVC,
+ hint: nil,
+ additionalScopes: nil,
+ nonce: nonce
+ ) { result, error in
+ self.handleSignInResult(result: result, error: error, resolve: resolve, reject: reject)
+ }
+ }
+ }
-// MARK: - Exceptions
+ // MARK: - signOut
-class SignInCancelledException: Exception {
- override var code: String { "SIGN_IN_CANCELLED" }
- override var reason: String { "User cancelled the sign-in flow" }
-}
+ @objc func signOut(_ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ GIDSignIn.sharedInstance.signOut()
+ resolve(nil)
+ }
-class NoSavedCredentialException: Exception {
- override var code: String { "NO_SAVED_CREDENTIAL_FOUND" }
- override var reason: String { "No saved credential found" }
-}
+ // MARK: - Helpers
-class NotConfiguredException: Exception {
- override var code: String { "NOT_CONFIGURED" }
- override var reason: String { "Google Sign-In is not configured. Call configure() first." }
-}
+ private func getPresentingViewController() -> UIViewController? {
+ guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let window = scene.windows.first,
+ let rootVC = window.rootViewController else {
+ return nil
+ }
-class GoogleSignInException: Exception {
- private let errorMessage: String
+ var topVC = rootVC
+ while let presentedVC = topVC.presentedViewController {
+ topVC = presentedVC
+ }
+ return topVC
+ }
+
+ private func handleSignInResult(result: GIDSignInResult?, error: Error?,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock) {
+ if let error = error {
+ let nsError = error as NSError
+
+ // Check for user cancellation
+ if nsError.domain == kGIDSignInErrorDomain && nsError.code == GIDSignInError.canceled.rawValue {
+ reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", error)
+ return
+ }
+
+ reject("GOOGLE_SIGN_IN_ERROR", error.localizedDescription, error)
+ return
+ }
- init(message: String) {
- self.errorMessage = message
- super.init()
+ guard let result = result,
+ let idToken = result.user.idToken?.tokenString else {
+ reject("GOOGLE_SIGN_IN_ERROR", "No ID token received", nil)
+ return
}
- override var code: String { "GOOGLE_SIGN_IN_ERROR" }
- override var reason: String { errorMessage }
+ let user = result.user
+ let profile = user.profile
+
+ let response: [String: Any] = [
+ "type": "success",
+ "data": [
+ "idToken": idToken,
+ "user": [
+ "id": user.userID ?? "",
+ "email": profile?.email ?? "",
+ "name": profile?.name ?? "",
+ "givenName": profile?.givenName ?? "",
+ "familyName": profile?.familyName ?? "",
+ "photo": profile?.imageURL(withDimension: 200)?.absoluteString ?? NSNull()
+ ] as [String: Any]
+ ] as [String: Any]
+ ]
+
+ resolve(response)
+ }
}
diff --git a/packages/expo/ios/ClerkUserProfileViewManager.m b/packages/expo/ios/ClerkUserProfileViewManager.m
new file mode 100644
index 00000000000..35eaf720ed9
--- /dev/null
+++ b/packages/expo/ios/ClerkUserProfileViewManager.m
@@ -0,0 +1,8 @@
+#import
+
+@interface RCT_EXTERN_MODULE(ClerkUserProfileViewManager, RCTViewManager)
+
+RCT_EXPORT_VIEW_PROPERTY(isDismissable, NSNumber)
+RCT_EXPORT_VIEW_PROPERTY(onProfileEvent, RCTBubblingEventBlock)
+
+@end
diff --git a/packages/expo/ios/ClerkUserProfileViewManager.swift b/packages/expo/ios/ClerkUserProfileViewManager.swift
new file mode 100644
index 00000000000..b8e9c269f6a
--- /dev/null
+++ b/packages/expo/ios/ClerkUserProfileViewManager.swift
@@ -0,0 +1,13 @@
+import React
+
+@objc(ClerkUserProfileViewManager)
+class ClerkUserProfileViewManager: RCTViewManager {
+
+ override static func requiresMainQueueSetup() -> Bool {
+ return true
+ }
+
+ override func view() -> UIView! {
+ return ClerkUserProfileNativeView()
+ }
+}
diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift
new file mode 100644
index 00000000000..d4a80aa6bc6
--- /dev/null
+++ b/packages/expo/ios/ClerkViewFactory.swift
@@ -0,0 +1,461 @@
+// ClerkViewFactory - Provides Clerk view controllers to the ClerkExpo module
+// This file is injected into the app target by the config plugin.
+// It uses `import ClerkKit` (SPM) which is only accessible from the app target.
+
+import UIKit
+import SwiftUI
+import Security
+import ClerkKit
+import ClerkKitUI
+import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol
+
+// MARK: - View Factory Implementation
+
+public class ClerkViewFactory: ClerkViewFactoryProtocol {
+ public static let shared = ClerkViewFactory()
+
+ private static let clerkLoadMaxAttempts = 30
+ private static let clerkLoadIntervalNs: UInt64 = 100_000_000
+
+ private init() {}
+
+ /// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first
+ /// (for extension apps sharing a keychain group), then falling back to the bundle identifier.
+ private static var keychainService: String? {
+ if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty {
+ return custom
+ }
+ return Bundle.main.bundleIdentifier
+ }
+
+ // Register this factory with the ClerkExpo module
+ public static func register() {
+ clerkViewFactory = shared
+ }
+
+ @MainActor
+ public func configure(publishableKey: String, bearerToken: String? = nil) async throws {
+ // Sync JS SDK's client token to native keychain so both SDKs share the same client.
+ // This handles the case where the user signed in via JS SDK but the native SDK
+ // has no device token (e.g., after app reinstall or first launch).
+ if let token = bearerToken, !token.isEmpty {
+ Self.writeNativeDeviceTokenIfNeeded(token)
+ } else {
+ Self.syncJSTokenToNativeKeychainIfNeeded()
+ }
+
+ Clerk.configure(publishableKey: publishableKey)
+
+ // Wait for Clerk to finish loading (cached data + API refresh).
+ // The static configure() fires off async refreshes; poll until loaded.
+ for _ in 0..) -> Void
+ ) -> UIViewController? {
+ let 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 {
+ return nil
+ }
+
+ var result: [String: Any] = [
+ "sessionId": session.id,
+ "status": String(describing: session.status)
+ ]
+
+ // Include user details if available
+ let user = session.user ?? Clerk.shared.user
+
+ 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
+ }
+
+ return result
+ }
+
+ @MainActor
+ public func signOut() async throws {
+ guard let sessionId = Clerk.shared.session?.id else { return }
+ try await Clerk.shared.auth.signOut(sessionId: sessionId)
+ }
+}
+
+// MARK: - Auth View Controller Wrapper
+
+class ClerkAuthWrapperViewController: UIHostingController {
+ private let completion: (Result<[String: Any], Error>) -> Void
+ private var authEventTask: Task?
+ private var completionCalled = false
+
+ init(mode: AuthView.Mode, dismissable: Bool, 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()
+ }
+
+ override func viewDidDisappear(_ animated: Bool) {
+ super.viewDidDisappear(animated)
+ if isBeingDismissed {
+ completeOnce(.success(["cancelled": true]))
+ }
+ }
+
+ private func completeOnce(_ result: Result<[String: Any], Error>) {
+ guard !completionCalled else { return }
+ completionCalled = true
+ completion(result)
+ }
+
+ private var initialSessionId: String? = Clerk.shared.session?.id
+
+ private func subscribeToAuthEvents() {
+ authEventTask = Task { @MainActor [weak self] in
+ for await event in Clerk.shared.auth.events {
+ guard let self = self, !self.completionCalled else { return }
+ switch event {
+ case .signInCompleted(let signIn):
+ let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id
+ if let sessionId, sessionId != self.initialSessionId {
+ self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"]))
+ self.dismiss(animated: true)
+ }
+ case .signUpCompleted(let signUp):
+ let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id
+ if let sessionId, sessionId != self.initialSessionId {
+ self.completeOnce(.success(["sessionId": sessionId, "type": "signUp"]))
+ self.dismiss(animated: true)
+ }
+ case .sessionChanged(_, let newSession):
+ if let sessionId = newSession?.id, sessionId != self.initialSessionId {
+ self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"]))
+ self.dismiss(animated: true)
+ }
+ default:
+ break
+ }
+ }
+ }
+ }
+}
+
+struct ClerkAuthWrapperView: View {
+ let mode: AuthView.Mode
+ let dismissable: Bool
+
+ var body: some View {
+ AuthView(mode: mode, isDismissable: dismissable)
+ .environment(Clerk.shared)
+ }
+}
+
+// MARK: - Profile View Controller Wrapper
+
+class ClerkProfileWrapperViewController: UIHostingController {
+ private let completion: (Result<[String: Any], Error>) -> Void
+ private var authEventTask: Task?
+ private var completionCalled = false
+
+ init(dismissable: Bool, 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()
+ }
+
+ override func viewDidDisappear(_ animated: Bool) {
+ super.viewDidDisappear(animated)
+ if isBeingDismissed {
+ completeOnce(.success(["dismissed": true]))
+ }
+ }
+
+ private func completeOnce(_ result: Result<[String: Any], Error>) {
+ guard !completionCalled else { return }
+ completionCalled = true
+ completion(result)
+ }
+
+ private func subscribeToAuthEvents() {
+ authEventTask = Task { @MainActor [weak self] in
+ for await event in Clerk.shared.auth.events {
+ guard let self = self, !self.completionCalled else { return }
+ switch event {
+ case .signedOut(let session):
+ self.completeOnce(.success(["sessionId": session.id]))
+ self.dismiss(animated: true)
+ default:
+ break
+ }
+ }
+ }
+ }
+}
+
+struct ClerkProfileWrapperView: View {
+ let dismissable: Bool
+
+ var body: some View {
+ UserProfileView(isDismissable: dismissable)
+ .environment(Clerk.shared)
+ }
+}
+
+// MARK: - Inline Auth View Wrapper (for embedded rendering)
+
+struct ClerkInlineAuthWrapperView: View {
+ let mode: AuthView.Mode
+ let dismissable: Bool
+ let onEvent: (String, [String: Any]) -> Void
+
+ // Track initial session to detect new sign-ins (same approach as Android)
+ @State private var initialSessionId: String? = Clerk.shared.session?.id
+ @State private var eventSent = false
+
+ private func sendAuthCompleted(sessionId: String, type: String) {
+ guard !eventSent, sessionId != initialSessionId else { return }
+ eventSent = true
+ onEvent(type, ["sessionId": sessionId, "type": type == "signUpCompleted" ? "signUp" : "signIn"])
+ }
+
+ var body: some View {
+ AuthView(mode: mode, isDismissable: dismissable)
+ .environment(Clerk.shared)
+ .task {
+ for await event in Clerk.shared.auth.events {
+ guard !eventSent else { continue }
+ switch event {
+ case .signInCompleted(let signIn):
+ // Use createdSessionId if available, fall back to current session
+ let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id
+ if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") }
+ case .signUpCompleted(let signUp):
+ let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id
+ if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signUpCompleted") }
+ case .sessionChanged(_, let newSession):
+ // Catches auth completion even when signIn/signUp events lack a sessionId
+ if let sessionId = newSession?.id { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") }
+ 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)
+ .environment(Clerk.shared)
+ .task {
+ for await event in Clerk.shared.auth.events {
+ switch event {
+ case .signedOut(let session):
+ onEvent("signedOut", ["sessionId": session.id])
+ default:
+ break
+ }
+ }
+ }
+ }
+}
+
diff --git a/packages/expo/ios/templates/ClerkViewFactory.swift b/packages/expo/ios/templates/ClerkViewFactory.swift
new file mode 100644
index 00000000000..0a96fcdf86c
--- /dev/null
+++ b/packages/expo/ios/templates/ClerkViewFactory.swift
@@ -0,0 +1,442 @@
+// ClerkViewFactory - Provides Clerk view controllers to the ClerkExpo module
+// This file is injected into the app target by the config plugin.
+// It uses `import ClerkKit` (SPM) which is only accessible from the app target.
+
+import UIKit
+import SwiftUI
+import Security
+import ClerkKit
+import ClerkKitUI
+import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol
+
+// MARK: - View Factory Implementation
+
+public class ClerkViewFactory: ClerkViewFactoryProtocol {
+ public static let shared = ClerkViewFactory()
+
+ private static let clerkLoadMaxAttempts = 30
+ private static let clerkLoadIntervalNs: UInt64 = 100_000_000
+
+ private init() {}
+
+ // Register this factory with the ClerkExpo module
+ public static func register() {
+ clerkViewFactory = shared
+ }
+
+ @MainActor
+ public func configure(publishableKey: String, bearerToken: String? = nil) async throws {
+ // Sync JS SDK's client token to native keychain so both SDKs share the same client.
+ // This handles the case where the user signed in via JS SDK but the native SDK
+ // has no device token (e.g., after app reinstall or first launch).
+ if let token = bearerToken, !token.isEmpty {
+ Self.writeNativeDeviceTokenIfNeeded(token)
+ } else {
+ Self.syncJSTokenToNativeKeychainIfNeeded()
+ }
+
+ Clerk.configure(publishableKey: publishableKey)
+
+ // Wait for Clerk to finish loading (cached data + API refresh).
+ // The static configure() fires off async refreshes; poll until loaded.
+ for _ in 0..) -> Void
+ ) -> UIViewController? {
+ let 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 {
+ return nil
+ }
+
+ var result: [String: Any] = [
+ "sessionId": session.id,
+ "status": String(describing: session.status)
+ ]
+
+ // Include user details if available
+ let user = session.user ?? Clerk.shared.user
+
+ 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
+ }
+
+ return result
+ }
+
+ @MainActor
+ public func signOut() async throws {
+ guard let sessionId = Clerk.shared.session?.id else { return }
+ try await Clerk.shared.auth.signOut(sessionId: sessionId)
+ }
+}
+
+// MARK: - Auth View Controller Wrapper
+
+class ClerkAuthWrapperViewController: UIHostingController {
+ private let completion: (Result<[String: Any], Error>) -> Void
+ private var authEventTask: Task?
+ private var completionCalled = false
+
+ init(mode: AuthView.Mode, dismissable: Bool, 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()
+ }
+
+ override func viewDidDisappear(_ animated: Bool) {
+ super.viewDidDisappear(animated)
+ if isBeingDismissed {
+ completeOnce(.failure(NSError(domain: "ClerkAuth", code: 3, userInfo: [NSLocalizedDescriptionKey: "Auth modal was dismissed"])))
+ }
+ }
+
+ private func completeOnce(_ result: Result<[String: Any], Error>) {
+ guard !completionCalled else { return }
+ completionCalled = true
+ completion(result)
+ }
+
+ private func subscribeToAuthEvents() {
+ authEventTask = Task { @MainActor [weak self] in
+ for await event in Clerk.shared.auth.events {
+ guard let self = self, !self.completionCalled else { return }
+ switch event {
+ case .signInCompleted(let signIn):
+ if let sessionId = signIn.createdSessionId {
+ self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"]))
+ self.dismiss(animated: true)
+ } else {
+ self.completeOnce(.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):
+ if let sessionId = signUp.createdSessionId {
+ self.completeOnce(.success(["sessionId": sessionId, "type": "signUp"]))
+ self.dismiss(animated: true)
+ } else {
+ self.completeOnce(.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.completeOnce(.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)
+ .environment(Clerk.shared)
+ }
+}
+
+// MARK: - Profile View Controller Wrapper
+
+class ClerkProfileWrapperViewController: UIHostingController {
+ private let completion: (Result<[String: Any], Error>) -> Void
+ private var authEventTask: Task?
+ private var completionCalled = false
+
+ init(dismissable: Bool, 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()
+ }
+
+ override func viewDidDisappear(_ animated: Bool) {
+ super.viewDidDisappear(animated)
+ if isBeingDismissed {
+ completeOnce(.failure(NSError(domain: "ClerkProfile", code: 3, userInfo: [NSLocalizedDescriptionKey: "Profile modal was dismissed"])))
+ }
+ }
+
+ private func completeOnce(_ result: Result<[String: Any], Error>) {
+ guard !completionCalled else { return }
+ completionCalled = true
+ completion(result)
+ }
+
+ private func subscribeToAuthEvents() {
+ authEventTask = Task { @MainActor [weak self] in
+ for await event in Clerk.shared.auth.events {
+ guard let self = self, !self.completionCalled else { return }
+ switch event {
+ case .signedOut(let session):
+ self.completeOnce(.success(["sessionId": session.id]))
+ self.dismiss(animated: true)
+ default:
+ break
+ }
+ }
+ // Stream ended without a sign-out event
+ guard let self = self else { return }
+ self.completeOnce(.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)
+ .environment(Clerk.shared)
+ }
+}
+
+// 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)
+ .environment(Clerk.shared)
+ .task {
+ for await event in Clerk.shared.auth.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)
+ .environment(Clerk.shared)
+ .task {
+ for await event in Clerk.shared.auth.events {
+ switch event {
+ case .signedOut(let session):
+ onEvent("signedOut", ["sessionId": session.id])
+ default:
+ break
+ }
+ }
+ }
+ }
+}
+
diff --git a/packages/expo/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 af6223f9149..25a4a6f52ff 100644
--- a/packages/expo/package.json
+++ b/packages/expo/package.json
@@ -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,10 +83,20 @@
"dist",
"android",
"ios",
+ "native",
+ "web",
+ "local-credentials",
+ "passkeys",
+ "secure-store",
+ "resource-cache",
+ "token-cache",
"google",
"apple",
+ "src/specs",
"expo-module.config.json",
- "app.plugin.js"
+ "react-native.config.js",
+ "app.plugin.js",
+ "app.plugin.d.ts"
],
"scripts": {
"build": "tsup",
@@ -107,12 +122,12 @@
"@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",
"expo-crypto": "^15.0.7",
"expo-local-authentication": "^13.8.0",
- "expo-modules-core": "^3.0.0",
"expo-secure-store": "^12.8.1",
"expo-web-browser": "^12.8.2",
"react-native": "^0.81.4"
@@ -125,7 +140,6 @@
"expo-constants": ">=12",
"expo-crypto": ">=12",
"expo-local-authentication": ">=13.5.0",
- "expo-modules-core": ">=3.0.0",
"expo-secure-store": ">=12.4.0",
"expo-web-browser": ">=12.5.0",
"react": "^18.0.0 || ^19.0.0",
@@ -163,5 +177,13 @@
},
"publishConfig": {
"access": "public"
+ },
+ "codegenConfig": {
+ "name": "ClerkExpoSpec",
+ "type": "all",
+ "jsSrcsDir": "src/specs",
+ "android": {
+ "javaPackageName": "expo.modules.clerk"
+ }
}
}
diff --git a/packages/expo/react-native.config.js b/packages/expo/react-native.config.js
new file mode 100644
index 00000000000..84cec6c149d
--- /dev/null
+++ b/packages/expo/react-native.config.js
@@ -0,0 +1,11 @@
+module.exports = {
+ dependency: {
+ platforms: {
+ ios: {},
+ android: {
+ packageImportPath: 'import expo.modules.clerk.ClerkPackage;',
+ packageInstance: 'new ClerkPackage()',
+ },
+ },
+ },
+};
diff --git a/packages/expo/src/constants.ts b/packages/expo/src/constants.ts
new file mode 100644
index 00000000000..9b36b334fbb
--- /dev/null
+++ b/packages/expo/src/constants.ts
@@ -0,0 +1,3 @@
+// Token cache key used by the Clerk JS SDK for storing the client JWT.
+// This must be consistent across all usages (ClerkProvider, native views, etc.)
+export const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt';
diff --git a/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts b/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts
index 5dc89a69982..9e562bc4f79 100644
--- a/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts
+++ b/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts
@@ -1,5 +1,4 @@
-import { requireNativeModule } from 'expo-modules-core';
-
+import NativeClerkGoogleSignIn from '../specs/NativeClerkGoogleSignIn';
import type {
CancelledResponse,
ConfigureParams,
@@ -11,23 +10,14 @@ import type {
SignInParams,
} from './types';
-// Type for the native module methods
-interface ClerkGoogleSignInNativeModule {
- configure(params: ConfigureParams): void;
- signIn(params: SignInParams): Promise;
- createAccount(params: CreateAccountParams): Promise;
- presentExplicitSignIn(params: ExplicitSignInParams): Promise;
- signOut(): Promise;
-}
-
-// Lazy-load the native module to avoid crashes when not available
-let _nativeModule: ClerkGoogleSignInNativeModule | null = null;
-
-function getNativeModule(): ClerkGoogleSignInNativeModule {
- if (!_nativeModule) {
- _nativeModule = requireNativeModule('ClerkGoogleSignIn');
+function getNativeModule(): NonNullable {
+ if (!NativeClerkGoogleSignIn) {
+ throw new Error(
+ 'ClerkGoogleSignIn native module is not available. ' +
+ 'Ensure the @clerk/expo plugin is added to your app.json and you have run a development build.',
+ );
}
- return _nativeModule;
+ return NativeClerkGoogleSignIn;
}
/**
@@ -84,7 +74,7 @@ export const ClerkGoogleOneTapSignIn = {
* @param params.autoSelectEnabled - Auto-select for single credential (default: false)
*/
configure(params: ConfigureParams): void {
- getNativeModule().configure(params);
+ getNativeModule().configure(params as any);
},
/**
@@ -101,7 +91,7 @@ export const ClerkGoogleOneTapSignIn = {
*/
async signIn(params?: SignInParams): Promise {
try {
- return await getNativeModule().signIn(params ?? {});
+ return (await getNativeModule().signIn((params as any) ?? null)) as unknown as OneTapResponse;
} catch (error) {
if (isErrorWithCode(error)) {
if (error.code === 'SIGN_IN_CANCELLED') {
@@ -128,7 +118,7 @@ export const ClerkGoogleOneTapSignIn = {
*/
async createAccount(params?: CreateAccountParams): Promise {
try {
- return await getNativeModule().createAccount(params ?? {});
+ return (await getNativeModule().createAccount((params as any) ?? null)) as unknown as OneTapResponse;
} catch (error) {
if (isErrorWithCode(error)) {
if (error.code === 'SIGN_IN_CANCELLED') {
@@ -155,7 +145,7 @@ export const ClerkGoogleOneTapSignIn = {
*/
async presentExplicitSignIn(params?: ExplicitSignInParams): Promise {
try {
- return await getNativeModule().presentExplicitSignIn(params ?? {});
+ return (await getNativeModule().presentExplicitSignIn((params as any) ?? null)) as unknown as OneTapResponse;
} catch (error) {
if (isErrorWithCode(error)) {
if (error.code === 'SIGN_IN_CANCELLED') {
diff --git a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
index 08e2e2a92b8..c297713a801 100644
--- a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
+++ b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
@@ -46,10 +46,26 @@ vi.mock('react-native', () => {
};
});
-vi.mock('expo-modules-core', () => {
+vi.mock('../../specs/NativeClerkModule', () => {
return {
- EventEmitter: vi.fn(),
- requireNativeModule: vi.fn(),
+ default: {
+ configure: vi.fn(),
+ getSession: vi.fn(),
+ getClientToken: vi.fn(),
+ signOut: vi.fn(),
+ },
+ };
+});
+
+vi.mock('../../specs/NativeClerkGoogleSignIn', () => {
+ return {
+ default: {
+ configure: vi.fn(),
+ signIn: vi.fn(),
+ createAccount: vi.fn(),
+ presentExplicitSignIn: vi.fn(),
+ signOut: vi.fn(),
+ },
};
});
diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts
index 0b64ce0c5be..1b9f82ad707 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,6 @@ export {
export * from './useSSO';
export * from './useOAuth';
export * from './useAuth';
+export * from './useNativeSession';
+export * from './useNativeAuthEvents';
+export * from './useUserProfileModal';
diff --git a/packages/expo/src/hooks/useNativeAuthEvents.ts b/packages/expo/src/hooks/useNativeAuthEvents.ts
new file mode 100644
index 00000000000..8ef9feeb83f
--- /dev/null
+++ b/packages/expo/src/hooks/useNativeAuthEvents.ts
@@ -0,0 +1,107 @@
+import { useEffect, useState } from 'react';
+import { NativeEventEmitter, Platform } from 'react-native';
+
+import NativeClerkModule from '../specs/NativeClerkModule';
+
+// 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: typeof NativeClerkModule | null = null;
+
+if (isNativeSupported) {
+ try {
+ ClerkExpo = NativeClerkModule;
+ } 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;
+ }
+
+ let subscription: { remove: () => void } | null = null;
+
+ try {
+ console.log(`[useNativeAuthEvents] SETUP: Creating NativeEventEmitter for ClerkExpo`);
+ const eventEmitter = new NativeEventEmitter(ClerkExpo as any);
+
+ 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..103037b6f1f
--- /dev/null
+++ b/packages/expo/src/hooks/useNativeSession.ts
@@ -0,0 +1,141 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Platform } from 'react-native';
+
+import NativeClerkModule from '../specs/NativeClerkModule';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Native session data structure (normalized)
+interface NativeSessionData {
+ sessionId?: string;
+ user?: {
+ id: string;
+ firstName?: string;
+ lastName?: string;
+ imageUrl?: string;
+ primaryEmailAddress?: string;
+ };
+}
+
+// Raw result from the native module (may vary by platform)
+interface NativeSessionRawResult {
+ sessionId?: string;
+ session?: { id: string };
+ user?: NativeSessionData['user'];
+}
+
+// Safely get the native module
+let ClerkExpo: typeof NativeClerkModule | null = null;
+
+if (isNativeSupported) {
+ try {
+ ClerkExpo = NativeClerkModule;
+ } 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 ID, if available
+ */
+ sessionId: string | 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 [sessionId, setSessionId] = 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()) as NativeSessionRawResult | null;
+ // Normalize: iOS returns { sessionId }, Android returns { session: { id } }
+ const id = result?.sessionId ?? result?.session?.id ?? null;
+ setSessionId(id);
+ setUser(result?.user ?? null);
+ } catch (error) {
+ console.log('[useNativeSession] Error fetching native session:', error);
+ setSessionId(null);
+ setUser(null);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ // Check native session on mount
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return {
+ isAvailable: isNativeSupported && !!ClerkExpo,
+ isLoading,
+ isSignedIn: !!sessionId,
+ sessionId,
+ user,
+ refresh,
+ };
+}
diff --git a/packages/expo/src/hooks/useUserProfileModal.ts b/packages/expo/src/hooks/useUserProfileModal.ts
new file mode 100644
index 00000000000..b403d7ec04d
--- /dev/null
+++ b/packages/expo/src/hooks/useUserProfileModal.ts
@@ -0,0 +1,124 @@
+import { useClerk } from '@clerk/react';
+import { useCallback, useRef } from 'react';
+import { Platform } from 'react-native';
+
+import NativeClerkModule from '../specs/NativeClerkModule';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Raw result from the native module (may vary by platform)
+type NativeSessionResult = {
+ sessionId?: string;
+ session?: { id: string };
+};
+
+// Safely get the native module
+let ClerkExpo: typeof NativeClerkModule | null = null;
+if (isNativeSupported) {
+ try {
+ ClerkExpo = NativeClerkModule;
+ } catch {
+ ClerkExpo = null;
+ }
+}
+
+export interface UseUserProfileModalReturn {
+ /**
+ * Present the native user profile modal.
+ *
+ * The returned promise resolves when the modal is dismissed.
+ * If the user signed out from within the profile modal,
+ * the JS SDK session is automatically cleared.
+ */
+ presentUserProfile: () => Promise;
+
+ /**
+ * Whether the native module supports presenting the profile modal.
+ */
+ isAvailable: boolean;
+}
+
+/**
+ * Imperative hook for presenting the native user profile modal.
+ *
+ * Call `presentUserProfile()` from a button's `onPress` to show the native
+ * profile management screen (SwiftUI on iOS, Jetpack Compose on Android).
+ * The promise resolves when the modal is dismissed.
+ *
+ * Sign-out is detected automatically — if the user signs out from within
+ * the profile modal, the JS SDK session is cleared so `useAuth()` updates
+ * reactively.
+ *
+ * @example
+ * ```tsx
+ * import { useUserProfileModal } from '@clerk/expo';
+ *
+ * function MyScreen() {
+ * const { presentUserProfile } = useUserProfileModal();
+ *
+ * return (
+ *
+ * Manage Profile
+ *
+ * );
+ * }
+ * ```
+ */
+export function useUserProfileModal(): UseUserProfileModalReturn {
+ const clerk = useClerk();
+ const presentingRef = useRef(false);
+
+ const presentUserProfile = useCallback(async () => {
+ if (presentingRef.current) {
+ return;
+ }
+
+ if (!isNativeSupported || !ClerkExpo?.presentUserProfile) {
+ return;
+ }
+
+ presentingRef.current = true;
+ try {
+ await ClerkExpo.presentUserProfile({
+ dismissable: true,
+ });
+
+ // 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?.()) as NativeSessionResult | null;
+ const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id);
+
+ if (!hasNativeSession) {
+ // 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 to update isSignedIn state
+ if (clerk?.signOut) {
+ try {
+ await clerk.signOut();
+ } catch {
+ // Best effort
+ }
+ }
+ }
+ } catch (error) {
+ // Dismissal resolves successfully with { dismissed: true }, so reaching
+ // here means a real native error (E_NOT_INITIALIZED, E_CREATE_FAILED, E_NO_ROOT_VC).
+ if (__DEV__) {
+ console.error('[useUserProfileModal] presentUserProfile failed:', error);
+ }
+ } finally {
+ presentingRef.current = false;
+ }
+ }, [clerk]);
+
+ return {
+ presentUserProfile,
+ isAvailable: isNativeSupported && !!ClerkExpo?.presentUserProfile,
+ };
+}
diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx
new file mode 100644
index 00000000000..9aa01ce884d
--- /dev/null
+++ b/packages/expo/src/native/AuthView.tsx
@@ -0,0 +1,164 @@
+import { ClerkRuntimeError } from '@clerk/shared/error';
+import { useCallback, useRef } from 'react';
+import { Platform, Text, View } from 'react-native';
+
+import { CLERK_CLIENT_JWT_KEY } from '../constants';
+import { getClerkInstance } from '../provider/singleton';
+import NativeClerkAuthView from '../specs/NativeClerkAuthView';
+import NativeClerkModule from '../specs/NativeClerkModule';
+import { tokenCache } from '../token-cache';
+import type { AuthViewProps } from './AuthView.types';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Safely get the native module
+let ClerkExpo: typeof NativeClerkModule | null = null;
+if (isNativeSupported) {
+ try {
+ ClerkExpo = NativeClerkModule;
+ } catch {
+ ClerkExpo = null;
+ }
+}
+
+export async function syncNativeSession(sessionId: string): Promise {
+ // Copy the native client's bearer token to the JS SDK's token cache
+ if (ClerkExpo?.getClientToken) {
+ const nativeClientToken = await ClerkExpo.getClientToken();
+ if (__DEV__) {
+ console.log(
+ '[syncNativeSession] getClientToken:',
+ nativeClientToken ? `${nativeClientToken.slice(0, 20)}...` : 'null',
+ );
+ }
+ if (nativeClientToken) {
+ await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken);
+ }
+ }
+
+ const clerkInstance = getClerkInstance();
+ if (!clerkInstance) {
+ throw new ClerkRuntimeError(
+ 'Clerk instance is not available. Ensure is mounted before using .',
+ { code: 'clerk_instance_not_available' },
+ );
+ }
+
+ // Reload resources using the native client's token
+ const clerkRecord = clerkInstance as unknown as Record;
+ if (typeof clerkRecord.__internal_reloadInitialResources === 'function') {
+ if (__DEV__) {
+ console.log('[syncNativeSession] reloading initial resources...');
+ }
+ await (clerkRecord.__internal_reloadInitialResources as () => Promise)();
+ if (__DEV__) {
+ console.log('[syncNativeSession] reload complete');
+ }
+ }
+
+ if (typeof clerkInstance.setActive === 'function') {
+ if (__DEV__) {
+ console.log('[syncNativeSession] calling setActive with session:', sessionId);
+ }
+ await clerkInstance.setActive({ session: sessionId });
+ if (__DEV__) {
+ console.log('[syncNativeSession] setActive complete');
+ }
+ }
+}
+
+/**
+ * A pre-built native authentication component that handles sign-in and sign-up flows.
+ *
+ * `AuthView` renders inline within your React Native view hierarchy, powered by:
+ * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios
+ * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android
+ *
+ * After authentication completes, the session is automatically synced with the JS SDK.
+ * Use `useAuth()`, `useUser()`, or `useSession()` in a `useEffect` to react to state changes.
+ *
+ * @example
+ * ```tsx
+ * import { AuthView } from '@clerk/expo/native';
+ * import { useAuth } from '@clerk/expo';
+ *
+ * export default function SignInScreen() {
+ * const { isSignedIn } = useAuth();
+ *
+ * useEffect(() => {
+ * if (isSignedIn) router.replace('/home');
+ * }, [isSignedIn]);
+ *
+ * return ;
+ * }
+ * ```
+ *
+ * @see {@link https://clerk.com/docs/components/authentication/sign-in} Clerk Sign-In Documentation
+ */
+export function AuthView({ mode = 'signInOrUp', isDismissable = false }: AuthViewProps) {
+ const authCompletedRef = useRef(false);
+
+ const syncSession = useCallback(async (sessionId: string) => {
+ if (authCompletedRef.current) {
+ return;
+ }
+
+ if (__DEV__) {
+ console.log('[AuthView] syncSession called with sessionId:', sessionId);
+ }
+
+ try {
+ await syncNativeSession(sessionId);
+ authCompletedRef.current = true;
+ if (__DEV__) {
+ console.log('[AuthView] syncSession succeeded');
+ }
+ } catch (err) {
+ if (__DEV__) {
+ console.error('[AuthView] Failed to sync session:', err);
+ }
+ }
+ }, []);
+
+ const handleAuthEvent = useCallback(
+ async (event: { nativeEvent: { type: string; data: string } }) => {
+ const { type, data: rawData } = event.nativeEvent;
+ if (__DEV__) {
+ console.log('[AuthView] onAuthEvent:', type, rawData);
+ }
+ const data: Record = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
+
+ if (type === 'signInCompleted' || type === 'signUpCompleted') {
+ const sessionId = data?.sessionId;
+ if (sessionId) {
+ await syncSession(sessionId);
+ } else if (__DEV__) {
+ console.warn('[AuthView] Auth event received but no sessionId in data:', data);
+ }
+ }
+ },
+ [syncSession],
+ );
+
+ if (!isNativeSupported || !NativeClerkAuthView) {
+ 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 (
+
+ );
+}
diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts
new file mode 100644
index 00000000000..2f316488827
--- /dev/null
+++ b/packages/expo/src/native/AuthView.types.ts
@@ -0,0 +1,40 @@
+/**
+ * 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 renders a native authentication UI inline (fills parent container).
+ * Use `useAuth()`, `useUser()`, or `useSession()` in a `useEffect` to react
+ * to authentication state changes.
+ */
+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.
+ *
+ * When `false`, the user must complete authentication to close the view.
+ * Use this for flows where authentication is required to proceed.
+ *
+ * @default false
+ */
+ isDismissable?: boolean;
+}
diff --git a/packages/expo/src/native/InlineAuthView.tsx b/packages/expo/src/native/InlineAuthView.tsx
new file mode 100644
index 00000000000..ad9472751ba
--- /dev/null
+++ b/packages/expo/src/native/InlineAuthView.tsx
@@ -0,0 +1,164 @@
+import { ClerkRuntimeError } from '@clerk/shared/error';
+import { useCallback, useRef } from 'react';
+import { Platform, StyleSheet, Text, View } from 'react-native';
+
+import { CLERK_CLIENT_JWT_KEY } from '../constants';
+import { getClerkInstance } from '../provider/singleton';
+import NativeClerkAuthView from '../specs/NativeClerkAuthView';
+import NativeClerkModule from '../specs/NativeClerkModule';
+import { tokenCache } from '../token-cache';
+import type { AuthViewMode } from './AuthView.types';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Safely get the native module
+let ClerkExpoModule: typeof NativeClerkModule | null = null;
+if (isNativeSupported) {
+ try {
+ ClerkExpoModule = NativeClerkModule;
+ } 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 false
+ */
+ isDismissable?: boolean;
+}
+
+/**
+ * An inline native authentication component that renders in-place.
+ *
+ * `InlineAuthView` renders directly within your React Native view hierarchy,
+ * allowing you to embed the native authentication UI anywhere in your layout.
+ *
+ * After authentication completes, the session is automatically synced with the JS SDK.
+ * Use `useAuth()`, `useUser()`, or `useSession()` in a `useEffect` to react to state changes.
+ *
+ * @example
+ * ```tsx
+ * import { InlineAuthView } from '@clerk/expo/native';
+ * import { useAuth } from '@clerk/expo';
+ *
+ * export default function SignInScreen() {
+ * const { isSignedIn } = useAuth();
+ *
+ * useEffect(() => {
+ * if (isSignedIn) router.replace('/home');
+ * }, [isSignedIn]);
+ *
+ * return (
+ *
+ * Welcome
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export function InlineAuthView({ mode = 'signInOrUp', isDismissable = false }: InlineAuthViewProps) {
+ const authCompletedRef = useRef(false);
+
+ const syncSession = useCallback(async (sessionId: string) => {
+ if (authCompletedRef.current) {
+ return;
+ }
+
+ try {
+ if (ClerkExpoModule?.getClientToken) {
+ const nativeClientToken = await ClerkExpoModule.getClientToken();
+ if (nativeClientToken) {
+ await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken);
+ }
+ }
+
+ const clerkInstance = getClerkInstance();
+ if (!clerkInstance) {
+ throw new ClerkRuntimeError(
+ 'Clerk instance is not available. Ensure is mounted before using .',
+ { code: 'clerk_instance_not_available' },
+ );
+ }
+
+ const clerkRecord = clerkInstance as unknown as Record;
+ if (typeof clerkRecord.__internal_reloadInitialResources === 'function') {
+ await (clerkRecord.__internal_reloadInitialResources as () => Promise)();
+ }
+
+ if (typeof clerkInstance.setActive === 'function') {
+ await clerkInstance.setActive({ session: sessionId });
+ }
+
+ authCompletedRef.current = true;
+ } catch (err) {
+ if (__DEV__) {
+ console.error('[InlineAuthView] Failed to sync session:', err);
+ }
+ }
+ }, []);
+
+ const handleAuthEvent = useCallback(
+ async (event: { nativeEvent: { type: string; data: string } }) => {
+ const { type, data: rawData } = event.nativeEvent;
+ if (__DEV__) {
+ console.log('[InlineAuthView] onAuthEvent:', type, rawData);
+ }
+ const data: Record = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
+
+ if (type === 'signInCompleted' || type === 'signUpCompleted') {
+ const sessionId = data?.sessionId;
+ if (sessionId) {
+ await syncSession(sessionId);
+ } else if (__DEV__) {
+ console.warn('[InlineAuthView] Auth event received but no sessionId in data:', data);
+ }
+ }
+ },
+ [syncSession],
+ );
+
+ if (!isNativeSupported || !NativeClerkAuthView) {
+ return (
+
+
+ {!isNativeSupported
+ ? 'Native InlineAuthView is only available on iOS and Android'
+ : 'Native InlineAuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ fallback: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ text: {
+ fontSize: 16,
+ color: '#666',
+ },
+});
diff --git a/packages/expo/src/native/InlineUserProfileView.tsx b/packages/expo/src/native/InlineUserProfileView.tsx
new file mode 100644
index 00000000000..cdb12bc1812
--- /dev/null
+++ b/packages/expo/src/native/InlineUserProfileView.tsx
@@ -0,0 +1,118 @@
+import { useClerk } from '@clerk/react';
+import { useCallback, useRef } from 'react';
+import { Platform, type StyleProp, StyleSheet, Text, View, type ViewStyle } from 'react-native';
+
+import NativeClerkModule from '../specs/NativeClerkModule';
+import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Safely get the native module
+let ClerkExpo: typeof NativeClerkModule | null = null;
+if (isNativeSupported) {
+ try {
+ ClerkExpo = NativeClerkModule;
+ } catch {
+ ClerkExpo = null;
+ }
+}
+
+export interface InlineUserProfileViewProps {
+ /**
+ * Whether the profile view can be dismissed by the user.
+ * @default false
+ */
+ isDismissable?: boolean;
+
+ /**
+ * Style applied to the container view.
+ */
+ style?: StyleProp;
+}
+
+/**
+ * An inline native user profile component that renders in-place.
+ *
+ * `InlineUserProfileView` renders directly within your React Native view hierarchy.
+ *
+ * Sign-out is detected automatically and synced with the JS SDK. Use `useAuth()` in a
+ * `useEffect` to react to sign-out.
+ *
+ * @example
+ * ```tsx
+ * import { InlineUserProfileView } from '@clerk/expo/native';
+ * import { useAuth } from '@clerk/expo';
+ *
+ * export default function ProfileScreen() {
+ * const { isSignedIn } = useAuth();
+ *
+ * useEffect(() => {
+ * if (!isSignedIn) router.replace('/sign-in');
+ * }, [isSignedIn]);
+ *
+ * return ;
+ * }
+ * ```
+ */
+export function InlineUserProfileView({ isDismissable = false, style }: InlineUserProfileViewProps) {
+ const clerk = useClerk();
+ const signOutTriggered = useRef(false);
+
+ const handleProfileEvent = useCallback(
+ async (event: { nativeEvent: { type: string; data: string } }) => {
+ const { type } = event.nativeEvent;
+
+ if (type === 'signedOut' && !signOutTriggered.current) {
+ signOutTriggered.current = true;
+
+ // Clear native session
+ try {
+ await ClerkExpo?.signOut();
+ } catch {
+ // 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);
+ }
+ }
+ }
+ },
+ [clerk],
+ );
+
+ if (!isNativeSupported || !NativeClerkUserProfileView) {
+ return (
+
+
+ {!isNativeSupported
+ ? 'Native InlineUserProfileView is only available on iOS and Android'
+ : 'Native InlineUserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ text: {
+ fontSize: 16,
+ color: '#666',
+ },
+});
diff --git a/packages/expo/src/native/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..2597bb5e072
--- /dev/null
+++ b/packages/expo/src/native/UserButton.tsx
@@ -0,0 +1,249 @@
+import { useClerk, useUser } from '@clerk/react';
+import { useEffect, useRef, useState } from 'react';
+import { Image, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+import NativeClerkModule from '../specs/NativeClerkModule';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Raw result from native module (may vary by platform)
+interface NativeSessionResult {
+ sessionId?: string;
+ session?: { id: string };
+ user?: { id: string; firstName?: string; lastName?: string; imageUrl?: string; primaryEmailAddress?: string };
+}
+
+// Safely get the native module
+let ClerkExpo: typeof NativeClerkModule | null = null;
+if (isNativeSupported) {
+ try {
+ ClerkExpo = NativeClerkModule;
+ } catch {
+ ClerkExpo = null;
+ }
+}
+
+interface NativeUser {
+ id: string;
+ firstName?: string;
+ lastName?: string;
+ imageUrl?: string;
+ primaryEmailAddress?: string;
+}
+
+/**
+ * Props for the UserButton component.
+ */
+// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+export interface UserButtonProps {}
+
+/**
+ * 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 native profile management modal.
+ *
+ * Sign-out is detected automatically and synced with the JS SDK, causing `useAuth()` to
+ * update reactively. Use `useAuth()` in a `useEffect` to react to sign-out.
+ *
+ * @example Basic usage in a header
+ * ```tsx
+ * import { UserButton } from '@clerk/expo/native';
+ *
+ * export default function Header() {
+ * return (
+ *
+ * My App
+ *
+ *
+ * );
+ * }
+ * ```
+ *
+ * @example Reacting to sign-out
+ * ```tsx
+ * import { UserButton } from '@clerk/expo/native';
+ * import { useAuth } from '@clerk/expo';
+ *
+ * export default function Header() {
+ * const { isSignedIn } = useAuth();
+ *
+ * useEffect(() => {
+ * if (!isSignedIn) router.replace('/sign-in');
+ * }, [isSignedIn]);
+ *
+ * return ;
+ * }
+ * ```
+ *
+ * @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(_props: UserButtonProps) {
+ const [nativeUser, setNativeUser] = useState(null);
+ const presentingRef = useRef(false);
+ const clerk = useClerk();
+ // Use the reactive user hook from clerk-react to observe sign-out state changes
+ const { user: clerkUser } = useUser();
+
+ // Fetch native user data on mount and when clerk user changes
+ useEffect(() => {
+ const fetchUser = async () => {
+ if (!isNativeSupported || !ClerkExpo?.getSession) {
+ return;
+ }
+
+ try {
+ const result = (await ClerkExpo.getSession()) as NativeSessionResult | null;
+ const hasSession = !!(result?.sessionId || result?.session?.id);
+ if (hasSession && result?.user) {
+ setNativeUser(result.user);
+ } else {
+ // Clear local state if no native session
+ setNativeUser(null);
+ }
+ } catch (err) {
+ console.error('[UserButton] Error fetching user:', err);
+ }
+ };
+
+ void fetchUser();
+ }, [clerkUser?.id]); // Re-fetch when clerk user changes (including sign-out)
+
+ // Derive the user to display - prefer native data, fall back to clerk-react data
+ const user: NativeUser | null =
+ nativeUser ??
+ (clerkUser
+ ? {
+ id: clerkUser.id,
+ firstName: clerkUser.firstName ?? undefined,
+ lastName: clerkUser.lastName ?? undefined,
+ imageUrl: clerkUser.imageUrl ?? undefined,
+ primaryEmailAddress: clerkUser.primaryEmailAddress?.emailAddress,
+ }
+ : null);
+
+ const handlePress = async () => {
+ if (presentingRef.current) {
+ return;
+ }
+
+ if (!isNativeSupported || !ClerkExpo?.presentUserProfile) {
+ return;
+ }
+
+ presentingRef.current = true;
+ try {
+ await ClerkExpo.presentUserProfile({
+ dismissable: true,
+ });
+
+ // 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?.()) as NativeSessionResult | null;
+ const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id);
+
+ if (!hasNativeSession) {
+ // Clear local state immediately for instant UI feedback
+ setNativeUser(null);
+
+ // Clear native session explicitly (may already be cleared, but ensure it)
+ try {
+ await ClerkExpo.signOut?.();
+ } catch {
+ // May already be signed out
+ }
+
+ // Sign out from JS SDK to update isSignedIn state
+ if (clerk?.signOut) {
+ try {
+ await clerk.signOut();
+ } catch {
+ // Even if signOut throws, try to force reload to clear stale state
+ const clerkRecord = clerk as unknown as Record;
+ if (typeof clerkRecord.__internal_reloadInitialResources === 'function') {
+ try {
+ await (clerkRecord.__internal_reloadInitialResources as () => Promise)();
+ } catch {
+ // Best effort
+ }
+ }
+ }
+ }
+ }
+ } catch (error) {
+ // Dismissal resolves successfully with { dismissed: true }, so reaching
+ // here means a real native error (E_NOT_INITIALIZED, E_CREATE_FAILED, E_NO_ROOT_VC).
+ if (__DEV__) {
+ console.error('[UserButton] presentUserProfile failed:', error);
+ }
+ } finally {
+ presentingRef.current = false;
+ }
+ };
+
+ // 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 (
+ void handlePress()}
+ style={styles.button}
+ >
+ {user?.imageUrl ? (
+
+ ) : (
+
+ {getInitials()}
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ button: {
+ width: '100%',
+ height: '100%',
+ overflow: 'hidden',
+ },
+ avatar: {
+ flex: 1,
+ backgroundColor: '#6366f1',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ avatarImage: {
+ width: '100%',
+ height: '100%',
+ },
+ avatarText: {
+ color: 'white',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ text: {
+ fontSize: 14,
+ color: '#666',
+ },
+});
diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo/src/native/UserProfileView.tsx
new file mode 100644
index 00000000000..0215bd4a3af
--- /dev/null
+++ b/packages/expo/src/native/UserProfileView.tsx
@@ -0,0 +1,132 @@
+import { useClerk } from '@clerk/react';
+import { useCallback, useRef } from 'react';
+import type { StyleProp, ViewStyle } from 'react-native';
+import { Platform, StyleSheet, Text, View } from 'react-native';
+
+import NativeClerkModule from '../specs/NativeClerkModule';
+import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView';
+
+// Check if native module is supported on this platform
+const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+// Safely get the native module
+let ClerkExpo: typeof NativeClerkModule | null = null;
+if (isNativeSupported) {
+ try {
+ ClerkExpo = NativeClerkModule;
+ } catch {
+ ClerkExpo = null;
+ }
+}
+
+/**
+ * Props for the UserProfileView component.
+ */
+export interface UserProfileViewProps {
+ /**
+ * Whether the inline profile view shows a dismiss button.
+ *
+ * This controls the native view's built-in dismiss button — it does not
+ * present a modal. To present a native modal, use the `useUserProfileModal()` hook.
+ *
+ * @default false
+ */
+ isDismissable?: boolean;
+
+ /**
+ * Style applied to the container view.
+ */
+ style?: StyleProp;
+}
+
+/**
+ * A pre-built native component for managing the user's profile and account settings.
+ *
+ * `UserProfileView` renders inline within your React Native view hierarchy, powered by:
+ * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios
+ * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android
+ *
+ * To present the profile as a native modal, use the `useUserProfileModal()` hook instead.
+ *
+ * Sign-out is detected automatically and synced with the JS SDK. Use `useAuth()` in a
+ * `useEffect` to react to sign-out.
+ *
+ * @example
+ * ```tsx
+ * import { UserProfileView } from '@clerk/expo/native';
+ * import { useAuth } from '@clerk/expo';
+ *
+ * export default function ProfileScreen() {
+ * const { isSignedIn } = useAuth();
+ *
+ * useEffect(() => {
+ * if (!isSignedIn) router.replace('/sign-in');
+ * }, [isSignedIn]);
+ *
+ * return ;
+ * }
+ * ```
+ *
+ * @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation
+ */
+export function UserProfileView({ isDismissable = false, style }: UserProfileViewProps) {
+ const clerk = useClerk();
+ const signOutTriggered = useRef(false);
+
+ const handleProfileEvent = useCallback(
+ async (event: { nativeEvent: { type: string; data: string } }) => {
+ const { type } = event.nativeEvent;
+
+ if (type === 'signedOut' && !signOutTriggered.current) {
+ signOutTriggered.current = true;
+
+ try {
+ await ClerkExpo?.signOut();
+ } catch {
+ // May already be signed out
+ }
+
+ if (clerk?.signOut) {
+ try {
+ await clerk.signOut();
+ } catch (err) {
+ console.warn('[UserProfileView] JS SDK sign out error:', err);
+ }
+ }
+ }
+ },
+ [clerk],
+ );
+
+ if (!isNativeSupported || !NativeClerkUserProfileView) {
+ 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..8ccd60b6f2c
--- /dev/null
+++ b/packages/expo/src/native/index.ts
@@ -0,0 +1,36 @@
+/**
+ * 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), renders inline
+ * - {@link UserProfileView} - User profile and account management, renders inline
+ * - {@link UserButton} - Avatar button that opens native profile modal
+ *
+ * @module @clerk/expo/native
+ */
+
+export { AuthView } from './AuthView';
+export type { AuthViewProps, AuthViewMode } from './AuthView.types';
+export { UserButton } from './UserButton';
+export type { UserButtonProps } from './UserButton';
+export { UserProfileView } from './UserProfileView';
+export type { UserProfileViewProps } from './UserProfileView';
diff --git a/packages/expo/src/plugin/withClerkExpo.ts b/packages/expo/src/plugin/withClerkExpo.ts
index d342ef370b4..0cddd5ff281 100644
--- a/packages/expo/src/plugin/withClerkExpo.ts
+++ b/packages/expo/src/plugin/withClerkExpo.ts
@@ -1,12 +1,45 @@
-import { type ConfigPlugin, createRunOncePlugin, withInfoPlist } from '@expo/config-plugins';
+import { type ConfigPlugin, createRunOncePlugin, withAppBuildGradle, withInfoPlist } from '@expo/config-plugins';
import pkg from '../../package.json';
/**
- * Expo config plugin for @clerk/expo.
- *
- * This plugin configures the iOS URL scheme required for Google Sign-In.
- * The native Android module is automatically linked via expo-module.config.json.
+ * 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;
+
+ // --- META-INF exclusion ---
+ if (!buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) {
+ // AGP 8+ uses `packaging` DSL, older versions use `packagingOptions`
+ const packagingMatch = buildGradle.match(/packaging\s*\{/) || buildGradle.match(/packagingOptions\s*\{/);
+ if (packagingMatch) {
+ const blockName = packagingMatch[0].trim().replace(/\s*\{$/, '');
+ const resourcesExclude = `${blockName} {
+ // Clerk Android SDK: exclude duplicate META-INF files
+ resources {
+ excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
+ }`;
+
+ buildGradle = buildGradle.replace(new RegExp(`${blockName}\\s*\\{`), resourcesExclude);
+ } else {
+ // No packaging block found; append one at the end of the android block
+ const androidBlockEnd = buildGradle.lastIndexOf('}');
+ if (androidBlockEnd !== -1) {
+ const packagingBlock = `\n packaging {\n resources {\n excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']\n }\n }\n`;
+ buildGradle = buildGradle.slice(0, androidBlockEnd) + packagingBlock + buildGradle.slice(androidBlockEnd);
+ }
+ }
+ }
+
+ modConfig.modResults.contents = buildGradle;
+ return modConfig;
+ });
+};
+
+/**
+ * Configures iOS URL scheme for Google Sign-In.
*/
const withClerkGoogleSignIn: ConfigPlugin = config => {
// Get the iOS URL scheme from environment or config.extra
@@ -42,4 +75,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. Configures iOS URL scheme for Google Sign-In (if env var is set)
+ * 2. Adds Android packaging exclusions to resolve dependency conflicts
+ *
+ * Native modules are registered via react-native.config.js and standard
+ * React Native autolinking (RCTViewManager / ReactPackage).
+ */
+const withClerkExpo: ConfigPlugin = 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 d76e3fa541c..68a4763bc10 100644
--- a/packages/expo/src/provider/ClerkProvider.tsx
+++ b/packages/expo/src/provider/ClerkProvider.tsx
@@ -2,9 +2,14 @@ import '../polyfills';
import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react';
import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal';
-import * as WebBrowser from 'expo-web-browser';
+import { useEffect, useRef } from 'react';
+import { Platform } from 'react-native';
import type { TokenCache } from '../cache/types';
+import { CLERK_CLIENT_JWT_KEY } from '../constants';
+import { useNativeAuthEvents } from '../hooks/useNativeAuthEvents';
+import NativeClerkModule from '../specs/NativeClerkModule';
+import { tokenCache as defaultTokenCache } from '../token-cache';
import { isNative, isWeb } from '../utils/runtime';
import { getClerkInstance } from './singleton';
import type { BuildClerkOptions } from './singleton/types';
@@ -59,9 +64,208 @@ export function ClerkProvider(props: ClerkProviderProps(null);
+ const initStartedRef = useRef(false);
+ const sessionSyncedRef = useRef(false);
+ // Reset refs when publishable key changes (hot-swap support)
+ useEffect(() => {
+ pendingNativeSessionRef.current = null;
+ initStartedRef.current = false;
+ sessionSyncedRef.current = false;
+ }, [pk]);
+
+ // 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 ClerkExpo = NativeClerkModule;
+
+ if (ClerkExpo?.configure) {
+ // Read the JS SDK's client JWT to sync with the native SDK
+ let bearerToken: string | null = null;
+ try {
+ bearerToken = (await defaultTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null;
+ } catch {
+ // Token cache may not be available
+ }
+ await ClerkExpo.configure(pk, bearerToken);
+
+ 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()) as {
+ sessionId?: string;
+ session?: { id: string };
+ } | null;
+ // Normalize: iOS returns { sessionId }, Android returns { session: { id } }
+ sessionId = nativeSession?.sessionId ?? nativeSession?.session?.id ?? null;
+ 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') ||
+ error.message.includes("TurboModuleRegistry.getEnforcing(...): 'ClerkExpo'"));
+ 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);
+ }
+ }
+ };
+ void 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);
+ }
+ };
+
+ void syncNativeAuthToJs();
+ }, [nativeAuthState, clerkInstance]);
+
if (isWeb()) {
// This is needed in order for useOAuth to work correctly on web.
- WebBrowser.maybeCompleteAuthSession();
+ // Must stay synchronous during render to catch the redirect URL before children mount.
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const WebBrowser = require('expo-web-browser');
+ WebBrowser.maybeCompleteAuthSession();
+ } catch {
+ // expo-web-browser not installed — SSO/OAuth on web won't work
+ }
}
return (
@@ -72,21 +276,13 @@ export function ClerkProvider(props: ClerkProviderProps
{children}
diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts
index 993f3bd2edc..7fc8e6228a6 100644
--- a/packages/expo/src/provider/singleton/createClerkInstance.ts
+++ b/packages/expo/src/provider/singleton/createClerkInstance.ts
@@ -18,6 +18,7 @@ import {
SessionJWTCache,
} from '../../cache';
import { MemoryTokenCache } from '../../cache/MemoryTokenCache';
+import { CLERK_CLIENT_JWT_KEY } from '../../constants';
import { errorThrower } from '../../errorThrower';
import { isNative } from '../../utils';
import type { BuildClerkOptions } from './types';
@@ -36,8 +37,6 @@ type FapiResponse = Response & {
payload: { errors?: Array<{ code: string }> } | null;
};
-const KEY = '__clerk_client_jwt';
-
let __internal_clerk: HeadlessBrowserClerk | BrowserClerk | undefined;
export function createClerkInstance(ClerkClass: typeof Clerk) {
@@ -57,7 +56,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
if (!__internal_clerk || hasKeyChanged) {
if (hasKeyChanged) {
- tokenCache.clearToken?.(KEY);
+ tokenCache.clearToken?.(CLERK_CLIENT_JWT_KEY);
}
const getToken = tokenCache.getToken;
@@ -103,10 +102,9 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
return Promise.resolve(true);
};
- if (createResourceCache) {
- const isClerkNetworkError = (err: unknown): boolean =>
- isClerkRuntimeError(err) && err.code === 'network_error';
+ const isClerkNetworkError = (err: unknown): boolean => isClerkRuntimeError(err) && err.code === 'network_error';
+ if (createResourceCache) {
const retryInitilizeResourcesFromFAPI = async () => {
try {
await __internal_clerk?.__internal_reloadInitialResources();
@@ -125,12 +123,9 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
ClientResourceCache.init({ publishableKey, storage: createResourceCache });
SessionJWTCache.init({ publishableKey, storage: createResourceCache });
- // At this point __internal_clerk is guaranteed to be defined (just created above)
-
- const clerk = __internal_clerk;
- clerk.addListener(({ client }) => {
+ __internal_clerk.addListener(({ client }) => {
// @ts-expect-error - This is an internal API
- const environment = clerk?.__internal_environment as EnvironmentResource;
+ const environment = __internal_clerk?.__internal_environment as EnvironmentResource;
if (environment) {
void EnvironmentResourceCache.save(environment.__internal_toSnapshot());
}
@@ -149,7 +144,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
}
});
- clerk.__internal_getCachedResources = async (): Promise<{
+ __internal_clerk.__internal_getCachedResources = async (): Promise<{
client: ClientJSONSnapshot | null;
environment: EnvironmentJSONSnapshot | null;
}> => {
@@ -173,7 +168,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
// Instructs the backend to parse the api token from the Authorization header.
requestInit.url?.searchParams.append('_is_native', '1');
- const jwt = await getToken(KEY);
+ const jwt = await getToken(CLERK_CLIENT_JWT_KEY);
(requestInit.headers as Headers).set('authorization', jwt || '');
// Instructs the backend that the request is from a mobile device.
@@ -189,7 +184,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
__internal_clerk.__internal_onAfterResponse(async (_: FapiRequestInit, response: FapiResponse) => {
const authHeader = response.headers.get('authorization');
if (authHeader) {
- await saveToken(KEY, authHeader);
+ await saveToken(CLERK_CLIENT_JWT_KEY, authHeader);
}
if (!nativeApiErrorShown && response.payload?.errors?.[0]?.code === 'native_api_disabled') {
@@ -200,6 +195,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
}
});
}
+ // At this point __internal_clerk is guaranteed to be defined
return __internal_clerk;
};
}
diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo/src/specs/NativeClerkAuthView.ts
new file mode 100644
index 00000000000..e4cffd1497d
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkAuthView.ts
@@ -0,0 +1,16 @@
+/* eslint-disable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */
+// These deep imports from react-native internals are required by codegen.
+import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
+import type { HostComponent, ViewProps } from 'react-native';
+import type { BubblingEventHandler } from 'react-native/Libraries/Types/CodegenTypes';
+/* eslint-enable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */
+
+type AuthEvent = Readonly<{ type: string; data: string }>;
+
+interface NativeProps extends ViewProps {
+ mode?: string;
+ isDismissable?: boolean;
+ onAuthEvent?: BubblingEventHandler;
+}
+
+export default codegenNativeComponent('ClerkAuthView') as HostComponent;
diff --git a/packages/expo/src/specs/NativeClerkGoogleSignIn.ts b/packages/expo/src/specs/NativeClerkGoogleSignIn.ts
new file mode 100644
index 00000000000..7ee7e3cc00b
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkGoogleSignIn.ts
@@ -0,0 +1,13 @@
+import type { TurboModule } from 'react-native';
+import { TurboModuleRegistry } from 'react-native';
+import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypesNamespace';
+
+export interface Spec extends TurboModule {
+ configure(params: UnsafeObject): void;
+ signIn(params: UnsafeObject | null): Promise;
+ createAccount(params: UnsafeObject | null): Promise;
+ presentExplicitSignIn(params: UnsafeObject | null): Promise;
+ signOut(): Promise;
+}
+
+export default TurboModuleRegistry.get('ClerkGoogleSignIn');
diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts
new file mode 100644
index 00000000000..1c38d2c1f92
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkModule.ts
@@ -0,0 +1,14 @@
+import type { TurboModule } from 'react-native';
+import { TurboModuleRegistry } from 'react-native';
+import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypesNamespace';
+
+export interface Spec extends TurboModule {
+ configure(publishableKey: string, bearerToken: string | null): Promise;
+ presentAuth(options: UnsafeObject): Promise;
+ presentUserProfile(options: UnsafeObject): Promise;
+ getSession(): Promise;
+ getClientToken(): Promise;
+ signOut(): Promise;
+}
+
+export default TurboModuleRegistry.get('ClerkExpo');
diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo/src/specs/NativeClerkUserProfileView.ts
new file mode 100644
index 00000000000..a6096769738
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkUserProfileView.ts
@@ -0,0 +1,15 @@
+/* eslint-disable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */
+// These deep imports from react-native internals are required by codegen.
+import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
+import type { HostComponent, ViewProps } from 'react-native';
+import type { BubblingEventHandler } from 'react-native/Libraries/Types/CodegenTypes';
+/* eslint-enable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */
+
+type ProfileEvent = Readonly<{ type: string; data: string }>;
+
+interface NativeProps extends ViewProps {
+ isDismissable?: boolean;
+ onProfileEvent?: BubblingEventHandler;
+}
+
+export default codegenNativeComponent('ClerkUserProfileView') as HostComponent;
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 b1bb8774b05..d79594cac57 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,11 +279,79 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
this.#eventBus.emit(clerkEvents.Status, 'loading');
this.#eventBus.prioritizedOn(clerkEvents.Status, status => (this.#status = status));
- if (this.#publishableKey) {
+ // TODO: Please check into refactoring the type logic here, the experimental type interface is using type Autocomplete = U | (T & Record);
+ // so we are casting options.experimental.runtimeEnvironment to avoid changing Autocomplete to use an intersection (&) instead of a union (|), as this could cause problems in other parts of the codebase if not also refactored.
+
+ if (
+ this.#publishableKey &&
+ (this.options.experimental as { runtimeEnvironment?: string } | undefined)?.runtimeEnvironment === 'headless' &&
+ this.options.Clerk
+ ) {
+ void this.loadHeadlessClerk();
+ } else if (this.#publishableKey) {
void this.getEntryChunks();
}
}
+ /**
+ * 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((listenerExtras, listener) => {
+ const unsubscribe = clerk.addListener(listener, listenerExtras.options);
+ listenerExtras.handlers.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();
+ }
+ }
+
get sdkMetadata() {
return this.clerkjs?.sdkMetadata || this.options.sdkMetadata || undefined;
}
diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts
index a3e3659130c..670a1a21ba0 100644
--- a/packages/shared/src/types/clerk.ts
+++ b/packages/shared/src/types/clerk.ts
@@ -1254,6 +1254,11 @@ export type ClerkOptions = ClerkOptionsNavigation &
*/
rethrowOfflineNetworkErrors: boolean;
commerce: boolean;
+ /**
+ * When set to `'headless'`, Clerk will skip script/chunk loading and initialize
+ * directly with the provided Clerk instance. Used by React Native / Expo.
+ */
+ runtimeEnvironment: 'headless';
},
Record
>;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 046eff3c79c..0a5b24875d4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -602,6 +602,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))
@@ -617,9 +620,6 @@ importers:
expo-local-authentication:
specifier: ^13.8.0
version: 13.8.0(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))
- expo-modules-core:
- specifier: ^3.0.0
- version: 3.0.25(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)
expo-secure-store:
specifier: ^12.8.1
version: 12.8.1(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))
@@ -2273,102 +2273,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'}
@@ -2381,6 +2483,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'}
@@ -2393,6 +2501,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'}
@@ -2405,24 +2519,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'}
@@ -7799,6 +7937,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'}
@@ -16520,81 +16663,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
@@ -23650,6 +23862,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