diff --git a/.changeset/remove-expo-present-auth.md b/.changeset/remove-expo-present-auth.md new file mode 100644 index 00000000000..4773e129856 --- /dev/null +++ b/.changeset/remove-expo-present-auth.md @@ -0,0 +1,9 @@ +--- +'@clerk/expo': minor +--- + +Update Expo's beta native prebuilt components to more closely match the behavior of Clerk's native iOS and Android SDKs. + +Previously, native auth and profile views relied on Expo-specific presentation behavior. `AuthView` and `UserProfileView` are now app-presented components, with dismissal handled through `onDismiss`. This also improves session synchronization between Clerk's JavaScript and native layers. + +**Note:** This includes native changes, so rebuild your native app after upgrading (`expo prebuild --clean` or a new EAS build). diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle index 4bc154232d7..b97e3005363 100644 --- a/packages/expo/android/build.gradle +++ b/packages/expo/android/build.gradle @@ -4,10 +4,9 @@ plugins { id 'org.jetbrains.kotlin.plugin.compose' version '2.1.20' } -// Required for React Native codegen to generate Fabric component descriptors -if (project.hasProperty("newArchEnabled") && project.newArchEnabled == "true") { - apply plugin: "com.facebook.react" -} +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() group = 'com.clerk.expo' version = '1.0.0' @@ -72,7 +71,7 @@ android { sourceSets { main { - java.srcDirs = ['src/main/java', "${project.buildDir}/generated/source/codegen/java"] + java.srcDirs = ['src/main/java'] } } } @@ -96,8 +95,7 @@ try { } dependencies { - // React Native - implementation 'com.facebook.react:react-native:+' + implementation project(':expo-modules-core') // Credential Manager for Google Sign-In with nonce support implementation "androidx.credentials:credentials:$credentialsVersion" diff --git a/packages/expo/android/src/main/AndroidManifest.xml b/packages/expo/android/src/main/AndroidManifest.xml index 4683222f409..e1131a6c37e 100644 --- a/packages/expo/android/src/main/AndroidManifest.xml +++ b/packages/expo/android/src/main/AndroidManifest.xml @@ -1,17 +1,4 @@ - - - - - diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt deleted file mode 100644 index 1c8049adba6..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt +++ /dev/null @@ -1,306 +0,0 @@ -package expo.modules.clerk - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.activity.compose.BackHandler -import androidx.activity.compose.setContent -import java.util.concurrent.atomic.AtomicBoolean -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.clerk.api.Clerk -import com.clerk.api.signin.SignIn -import com.clerk.api.signin.prepareSecondFactor -import com.clerk.api.signup.SignUp -import com.clerk.api.signup.prepareVerification -import com.clerk.api.network.serialization.onSuccess -import com.clerk.api.network.serialization.onFailure -import com.clerk.api.network.serialization.errorMessage -import com.clerk.ui.auth.AuthView -import kotlinx.coroutines.delay - -/** - * Activity that hosts Clerk's AuthView Compose component. - * - * This activity is launched from ClerkExpoModule to present a full-screen - * authentication modal (sign-in, sign-up, or combined flow). - * - * Intent extras: - * - "mode": String - "signIn", "signUp", or "signInOrUp" (default) - * - "dismissable": Boolean - whether back press dismisses (default: true) - * - * Result: - * - RESULT_OK: Auth completed successfully (session is available via Clerk.session) - * - RESULT_CANCELED: User dismissed the modal - */ -class ClerkAuthActivity : ComponentActivity() { - - companion object { - private const val TAG = "ClerkAuthActivity" - private const val CLIENT_SYNC_MAX_ATTEMPTS = 30 - private const val CLIENT_SYNC_INTERVAL_MS = 100L - private const val POLL_INTERVAL_MS = 500L - - private fun debugLog(tag: String, message: String) { - if (BuildConfig.DEBUG) { - Log.d(tag, message) - } - } - } - - private val authCompleteGuard = AtomicBoolean(false) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val mode = intent.getStringExtra(ClerkExpoModule.EXTRA_MODE) ?: "signInOrUp" - val dismissable = intent.getBooleanExtra(ClerkExpoModule.EXTRA_DISMISSABLE, true) - - // Track if we had a session when we started (to detect new sign-in) - val initialSession = Clerk.session - debugLog(TAG, "onCreate - hasInitialSession: ${initialSession != null}, mode: $mode") - - setContent { - // Observe initialization state - val isInitialized by Clerk.isInitialized.collectAsStateWithLifecycle() - - // Observe both session and user state for completion - val session by Clerk.sessionFlow.collectAsStateWithLifecycle() - val user by Clerk.userFlow.collectAsStateWithLifecycle() - - // Track if the client has been synced (environment is ready) - // We need to wait for the client to sync before showing AuthView - var isClientReady by remember { mutableStateOf(false) } - - // Track when auth is complete to hide AuthView before finishing - // This prevents the "NavDisplay backstack cannot be empty" crash - var isAuthComplete by remember { mutableStateOf(false) } - - // Wait for SDK to be fully initialized AND client to sync - // The client sync happens after isInitialized becomes true - LaunchedEffect(isInitialized) { - if (isInitialized) { - // Give the client a moment to sync after initialization - // The SDK needs time to fetch the environment configuration - var attempts = 0 - while (attempts < CLIENT_SYNC_MAX_ATTEMPTS) { - val client = Clerk.client - if (client != null) { - debugLog(TAG, "Client is ready") - isClientReady = true - break - } - delay(CLIENT_SYNC_INTERVAL_MS) - attempts++ - } - if (!isClientReady) { - Log.w(TAG, "Client did not become ready after 3 seconds, showing AuthView anyway") - isClientReady = true - } - } - } - - // Track last signUp ID to detect when a new signUp is created - var lastSignUpId by remember { mutableStateOf(null) } - // Track if we've already triggered prepareVerification for this signUp - var preparedSignUpId by remember { mutableStateOf(null) } - - // Track if we've already triggered prepareSecondFactor for this signIn - var preparedSecondFactorSignInId by remember { mutableStateOf(null) } - - // Monitor signUp state changes and manually trigger prepareVerification - LaunchedEffect(isClientReady) { - if (isClientReady) { - while (true) { - delay(POLL_INTERVAL_MS) - val client = Clerk.client - val signUp = client?.signUp - - if (signUp != null && signUp.id != lastSignUpId) { - lastSignUpId = signUp.id - debugLog(TAG, "New signUp detected, status: ${signUp.status}") - } - - // Manually trigger prepareVerification if needed - // This is a workaround for clerk-android-ui not calling prepareVerification - if (signUp != null && - signUp.id != preparedSignUpId && - signUp.emailAddress != null && - signUp.status == SignUp.Status.MISSING_REQUIREMENTS) { - - val emailVerification = signUp.verifications?.get("email_address") - // Only prepare if email is unverified - if (emailVerification?.status?.name == "UNVERIFIED") { - preparedSignUpId = signUp.id - - try { - val result = signUp.prepareVerification( - SignUp.PrepareVerificationParams.Strategy.EmailCode() - ) - result - .onSuccess { - debugLog(TAG, "prepareVerification succeeded") - } - .onFailure { error -> - Log.e(TAG, "prepareVerification failed: ${error.errorMessage}") - } - } catch (e: Exception) { - Log.e(TAG, "prepareVerification exception: ${e.message}") - } - } - } - - // Manually trigger prepareSecondFactor for MFA if needed - // This is a workaround for clerk-android-ui not calling prepareSecondFactor - val signIn = client?.signIn - if (signIn != null && - signIn.id != preparedSecondFactorSignInId && - signIn.status == SignIn.Status.NEEDS_SECOND_FACTOR) { - - preparedSecondFactorSignInId = signIn.id - - try { - val result = signIn.prepareSecondFactor() - result - .onSuccess { updatedSignIn -> - debugLog(TAG, "prepareSecondFactor succeeded, status: ${updatedSignIn.status}") - } - .onFailure { error -> - Log.e(TAG, "prepareSecondFactor failed: ${error.errorMessage}") - // Reset so we can retry - preparedSecondFactorSignInId = null - } - } catch (e: Exception) { - Log.e(TAG, "prepareSecondFactor exception: ${e.message}") - // Reset so we can retry - preparedSecondFactorSignInId = null - } - } - - // Check if auth completed - finish activity immediately - val currentSession = Clerk.session - if (currentSession != null && authCompleteGuard.compareAndSet(false, true)) { - isAuthComplete = true - - val resultIntent = Intent().apply { - putExtra("sessionId", currentSession.id) - putExtra("userId", currentSession.user?.id ?: Clerk.user?.id) - } - setResult(Activity.RESULT_OK, resultIntent) - finish() - break - } - } - } - } - - // Backup: Also listen for session via Flow (in case polling misses it) - LaunchedEffect(session) { - if (session != null && initialSession == null && authCompleteGuard.compareAndSet(false, true)) { - // Mark auth as complete FIRST to hide AuthView - // This prevents the "NavDisplay backstack cannot be empty" crash - isAuthComplete = true - - // Small delay to let the UI update before finishing - delay(100) - - // Auth completed - return session info - val resultIntent = Intent().apply { - putExtra("sessionId", session?.id) - putExtra("userId", session?.user?.id ?: user?.id) - } - setResult(Activity.RESULT_OK, resultIntent) - finish() - } - } - - // Handle back press - if (dismissable) { - BackHandler { - setResult(Activity.RESULT_CANCELED) - finish() - } - } else { - // Block back press when not dismissable - BackHandler { /* Do nothing */ } - } - - // Render Clerk's AuthView in a Material3 surface - MaterialTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - when { - isAuthComplete -> { - // Auth completed - show success indicator while finishing - // This prevents AuthView from crashing with empty navigation backstack - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator( - modifier = Modifier.size(48.dp) - ) - Text( - text = "Signed in!", - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - isClientReady -> { - // Client is ready, show AuthView - AuthView( - modifier = Modifier.fillMaxSize(), - clerkTheme = Clerk.customTheme - ) - } - else -> { - // Show loading while waiting for client to sync - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator( - modifier = Modifier.size(48.dp) - ) - Text( - text = "Loading...", - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - } - } - } - } - } -} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt deleted file mode 100644 index ce948f7a8a4..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt +++ /dev/null @@ -1,185 +0,0 @@ -package expo.modules.clerk - -import android.content.Context -import android.content.ContextWrapper -import android.util.Log -import android.widget.FrameLayout -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Recomposer -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.AndroidUiDispatcher -import androidx.compose.ui.platform.ComposeView -import androidx.lifecycle.ViewModelStore -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.setViewTreeLifecycleOwner -import androidx.lifecycle.setViewTreeViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.savedstate.compose.LocalSavedStateRegistryOwner -import androidx.savedstate.setViewTreeSavedStateRegistryOwner -import com.clerk.api.Clerk -import com.clerk.ui.auth.AuthView -import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.ReactContext -import com.facebook.react.uimanager.events.RCTEventEmitter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -private const val TAG = "ClerkAuthExpoView" - -private fun debugLog(tag: String, message: String) { - if (BuildConfig.DEBUG) { - Log.d(tag, message) - } -} - -class ClerkAuthNativeView(context: Context) : FrameLayout(context) { - var mode: String = "signInOrUp" - var isDismissable: Boolean = true - - private val activity: ComponentActivity? = findActivity(context).also { - // At cold start, ClerkExpoModule.configure() may run before React's - // host-resume sync — meaning getCurrentActivity() returns null there. - // This view's construction is a reliable second hook: we know the Activity - // is available (we just walked the context to find it) and we're about to - // render Google sign-in / passkey buttons that need it. - if (it != null) Clerk.attachActivity(it) - } - - // Per-view ViewModelStoreOwner so the AuthView's ViewModels (including its - // navigation state) are scoped to THIS view instance, not the activity. - // Without this, the AuthView's navigation persists across mount/unmount - // cycles within the same activity, leaving the user stuck on whatever screen - // (e.g. "Get help") was last navigated to before sign-out. - private val viewModelStoreOwner = object : ViewModelStoreOwner { - private val store = ViewModelStore() - override val viewModelStore: ViewModelStore = store - } - - private var recomposer: Recomposer? = null - private var recomposerJob: kotlinx.coroutines.Job? = null - - private val composeView = ComposeView(context).also { view -> - activity?.let { act -> - view.setViewTreeLifecycleOwner(act) - view.setViewTreeViewModelStoreOwner(act) - view.setViewTreeSavedStateRegistryOwner(act) - - // Create an explicit Recomposer to bypass windowRecomposer resolution. - // In Compose 1.7+, windowRecomposer looks at rootView which may not have - // lifecycle owners in React Native Fabric's detached view trees. - val recomposerContext = AndroidUiDispatcher.Main - val newRecomposer = Recomposer(recomposerContext) - recomposer = newRecomposer - view.setParentCompositionContext(newRecomposer) - val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob()) - recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job] - scope.launch { - newRecomposer.runRecomposeAndApplyChanges() - } - } - addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) - } - - override fun onDetachedFromWindow() { - recomposer?.cancel() - recomposerJob?.cancel() - // Clear our per-view ViewModelStore so any AuthView ViewModels are GC'd. - viewModelStoreOwner.viewModelStore.clear() - super.onDetachedFromWindow() - } - - // Track the initial session to detect new sign-ins. Captured at construction - // time, but may capture a stale session if the view is mounted before signOut - // has finished clearing local state — so the LaunchedEffect below uses - // session id inequality (not null-to-value) to detect new sign-ins. - private var initialSessionId: String? = Clerk.session?.id - private var authCompletedSent: Boolean = false - - fun setupView() { - debugLog(TAG, "setupView - mode: $mode, isDismissable: $isDismissable, activity: $activity") - - composeView.setContent { - val session by Clerk.sessionFlow.collectAsStateWithLifecycle() - - // Detect auth completion: any session that's different from the one we - // started with (captures fresh sign-ins, sign-in-after-sign-out, etc.) - LaunchedEffect(session) { - val currentSession = session - val currentId = currentSession?.id - if (currentSession != null && currentId != initialSessionId && !authCompletedSent) { - debugLog(TAG, "Auth completed - new session: $currentId (initial: $initialSessionId)") - authCompletedSent = true - sendEvent("signInCompleted", mapOf( - "sessionId" to currentSession.id, - "type" to "signIn" - )) - } - } - - // Provide the Activity as ViewModelStoreOwner so Clerk's viewModel() calls work - val content = @androidx.compose.runtime.Composable { - MaterialTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - AuthView( - modifier = Modifier.fillMaxSize(), - clerkTheme = Clerk.customTheme - ) - } - } - } - - if (activity != null) { - CompositionLocalProvider( - // Per-view ViewModelStore so AuthView's navigation state doesn't - // leak between mounts within the same MainActivity lifetime. - LocalViewModelStoreOwner provides viewModelStoreOwner, - LocalLifecycleOwner provides activity, - LocalSavedStateRegistryOwner provides activity, - ) { - content() - } - } else { - Log.e(TAG, "No ComponentActivity found!") - content() - } - } - } - - private fun sendEvent(type: String, data: Map) { - val reactContext = context as? ReactContext ?: return - val eventData = Arguments.createMap().apply { - putString("type", type) - // Serialize data as JSON string for codegen event - val jsonString = try { - org.json.JSONObject(data).toString() - } catch (e: Exception) { - "{}" - } - putString("data", jsonString) - } - reactContext.getJSModule(RCTEventEmitter::class.java) - .receiveEvent(id, "onAuthEvent", eventData) - } - - companion object { - fun findActivity(context: Context): ComponentActivity? { - var ctx: Context? = context - while (ctx != null) { - if (ctx is ComponentActivity) return ctx - ctx = (ctx as? ContextWrapper)?.baseContext - } - return null - } - } -} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt deleted file mode 100644 index 9ff989d9ea8..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt +++ /dev/null @@ -1,38 +0,0 @@ -package expo.modules.clerk - -import com.facebook.react.common.MapBuilder -import com.facebook.react.uimanager.SimpleViewManager -import com.facebook.react.uimanager.ThemedReactContext -import com.facebook.react.uimanager.annotations.ReactProp -import com.facebook.react.viewmanagers.ClerkAuthViewManagerInterface - -class ClerkAuthViewManager : SimpleViewManager(), - ClerkAuthViewManagerInterface { - - override fun getName(): String = "ClerkAuthView" - - override fun createViewInstance(reactContext: ThemedReactContext): ClerkAuthNativeView { - return ClerkAuthNativeView(reactContext) - } - - @ReactProp(name = "mode") - override fun setMode(view: ClerkAuthNativeView, mode: String?) { - view.mode = mode ?: "signInOrUp" - view.setupView() - } - - @ReactProp(name = "isDismissable") - override fun setIsDismissable(view: ClerkAuthNativeView, isDismissable: Boolean) { - view.isDismissable = isDismissable - view.setupView() - } - - override fun getExportedCustomBubblingEventTypeConstants(): MutableMap? { - return MapBuilder.builder() - .put("onAuthEvent", MapBuilder.of( - "phasedRegistrationNames", - MapBuilder.of("bubbled", "onAuthEvent") - )) - .build() as MutableMap - } -} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt new file mode 100644 index 00000000000..97cb37699d5 --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt @@ -0,0 +1,105 @@ +package expo.modules.clerk + +import android.content.Context +import android.util.Log +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import com.clerk.api.Clerk +import com.clerk.ui.auth.AuthView +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.viewevent.EventDispatcher + +private const val TAG = "ClerkAuthViewModule" + +private fun debugLog(tag: String, message: String) { + if (BuildConfig.DEBUG) { + Log.d(tag, message) + } +} + +class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) { + var isDismissible: Boolean = true + var mode: String? = null + + private val onAuthEvent by EventDispatcher() + + init { + // At cold start, ClerkExpoModule.configure() may run before React's + // host-resume sync, so this view's construction is a reliable second hook. + activity?.let { Clerk.attachActivity(it) } + } + + // Per-view ViewModelStoreOwner so the AuthView's ViewModels (including its + // navigation state) are scoped to THIS view instance, not the activity. + // Without this, the AuthView's navigation persists across mount/unmount + // cycles within the same activity, leaving the user stuck on whatever screen + // (e.g. "Get help") was last navigated to before sign-out. + private val viewModelStoreOwner = object : ViewModelStoreOwner { + private val store = ViewModelStore() + override val viewModelStore: ViewModelStore = store + } + + private var dismissalEventSent: Boolean = false + + override fun localViewModelStoreOwner(): ViewModelStoreOwner = viewModelStoreOwner + + override fun onHostDetachedFromWindow() { + // Clear our per-view ViewModelStore so any AuthView ViewModels are GC'd. + viewModelStoreOwner.viewModelStore.clear() + } + + @Composable + override fun Content() { + debugLog(TAG, "setupView - isDismissible: $isDismissible, activity: $activity") + + AuthView( + modifier = Modifier.fillMaxSize(), + clerkTheme = Clerk.customTheme, + isDismissable = isDismissible, + onDismiss = ::sendDismissEvent, + onAuthComplete = { + sendDismissEvent() + }, + ) + } + + private fun sendEvent(type: String) { + onAuthEvent(mapOf("type" to type)) + } + + private fun sendDismissEvent() { + if (dismissalEventSent) return + dismissalEventSent = true + sendEvent("dismissed") + } +} + +class ClerkAuthViewModule : Module() { + override fun definition() = ModuleDefinition { + Name("ClerkAuthView") + + View(ClerkAuthNativeView::class) { + Events("onAuthEvent") + + Prop("mode") { view: ClerkAuthNativeView, mode: String? -> + // clerk-android AuthView does not currently expose a public mode parameter. + // Keep this prop as an intentional no-op for cross-platform API parity. + view.mode = mode + } + + Prop("isDismissible") { view: ClerkAuthNativeView, isDismissible: Boolean -> + view.isDismissible = isDismissible + } + + OnViewDidUpdateProps { view: ClerkAuthNativeView -> + view.setupView() + } + + } + } +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt new file mode 100644 index 00000000000..67d5f6d681c --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt @@ -0,0 +1,90 @@ +package expo.modules.clerk + +import android.content.Context +import android.content.ContextWrapper +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Recomposer +import androidx.compose.ui.platform.AndroidUiDispatcher +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.savedstate.compose.LocalSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.views.ExpoView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + protected val activity: ComponentActivity? = findActivity(context) + + private var recomposer: Recomposer? = null + private var recomposerJob: kotlinx.coroutines.Job? = null + + private val composeView = ComposeView(context).also { view -> + activity?.let { act -> + view.setViewTreeLifecycleOwner(act) + view.setViewTreeViewModelStoreOwner(act) + view.setViewTreeSavedStateRegistryOwner(act) + + val recomposerContext = AndroidUiDispatcher.Main + val newRecomposer = Recomposer(recomposerContext) + recomposer = newRecomposer + view.setParentCompositionContext(newRecomposer) + val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob()) + recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job] + scope.launch { + newRecomposer.runRecomposeAndApplyChanges() + } + } + addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + override fun onDetachedFromWindow() { + recomposer?.cancel() + recomposerJob?.cancel() + onHostDetachedFromWindow() + super.onDetachedFromWindow() + } + + fun setupView() { + composeView.setContent { + val viewModelStoreOwner = localViewModelStoreOwner() + + if (activity != null && viewModelStoreOwner != null) { + CompositionLocalProvider( + LocalViewModelStoreOwner provides viewModelStoreOwner, + LocalLifecycleOwner provides activity, + LocalSavedStateRegistryOwner provides activity, + ) { + Content() + } + } else { + Content() + } + } + } + + protected open fun localViewModelStoreOwner(): ViewModelStoreOwner? = activity + + protected open fun onHostDetachedFromWindow() {} + + @Composable + protected abstract fun Content() + + companion object { + fun findActivity(context: Context): ComponentActivity? { + var ctx: Context? = context + while (ctx != null) { + if (ctx is ComponentActivity) return ctx + ctx = (ctx as? ContextWrapper)?.baseContext + } + return null + } + } +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index 7c822cfda40..9ef266fb523 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -1,24 +1,22 @@ package expo.modules.clerk -import android.app.Activity import android.content.Context -import android.content.Intent import android.util.Log import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.clerk.api.Clerk +import com.clerk.api.network.model.client.Client +import com.clerk.api.network.model.error.firstMessage import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.ui.ClerkColors import com.clerk.api.ui.ClerkDesign import com.clerk.api.ui.ClerkTheme -import com.facebook.react.bridge.ActivityEventListener -import com.facebook.react.bridge.Promise -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.ReactMethod -import com.facebook.react.bridge.ReadableMap -import com.facebook.react.bridge.WritableNativeMap +import expo.modules.kotlin.Promise +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -33,75 +31,111 @@ private fun debugLog(tag: String, message: String) { } } -class ClerkExpoModule(reactContext: ReactApplicationContext) : - NativeClerkModuleSpec(reactContext), - ActivityEventListener { +class ClerkExpoModule : Module() { + private val coroutineScope = CoroutineScope(Dispatchers.Main) + private var clientStateObserverJob: Job? = null + private var lastObservedClient: Client? = null + private var configuredPublishableKey: String? = null companion object { - const val CLERK_AUTH_REQUEST_CODE = 9001 - const val CLERK_PROFILE_REQUEST_CODE = 9002 + private var sharedInstance: ClerkExpoModule? = null - // Intent extras - const val EXTRA_DISMISSABLE = "dismissable" - const val EXTRA_PUBLISHABLE_KEY = "publishableKey" - const val EXTRA_MODE = "mode" + fun emitRefreshClient() { + sharedInstance?.sendEvent("refreshClient", emptyMap()) + } + } - // Result extras - const val RESULT_SESSION_ID = "sessionId" - const val RESULT_CANCELLED = "cancelled" + override fun definition() = ModuleDefinition { + Name("ClerkExpo") - // Pending promises for activity results - private var pendingAuthPromise: Promise? = null - private var pendingProfilePromise: Promise? = null + Events("refreshClient") - // Store publishable key for passing to activities - private var publishableKey: String? = null - } + OnCreate { + sharedInstance = this@ClerkExpoModule + } - private val coroutineScope = CoroutineScope(Dispatchers.Main) + OnDestroy { + if (sharedInstance === this@ClerkExpoModule) { + sharedInstance = null + } + clientStateObserverJob?.cancel() + clientStateObserverJob = null + } - init { - reactContext.addActivityEventListener(this) + AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise -> + configure(pubKey, bearerToken, promise) + } + + AsyncFunction("getSession") { promise: Promise -> + getSession(promise) + } + + AsyncFunction("getClientToken") { promise: Promise -> + getClientToken(promise) + } + + AsyncFunction("refreshClient") { promise: Promise -> + refreshClient(promise) + } } - override fun getName(): String = "ClerkExpo" + private val reactContext: Context? + get() = appContext.reactContext + + private fun startClientStateObserver() { + if (clientStateObserverJob != null) { + return + } + + lastObservedClient = Clerk.clientFlow.value + + clientStateObserverJob = coroutineScope.launch { + Clerk.clientFlow.collect { client -> + if (client == lastObservedClient) { + return@collect + } + + lastObservedClient = client + emitRefreshClient() + } + } + } // MARK: - configure - @ReactMethod - override fun configure(pubKey: String, bearerToken: String?, promise: Promise) { + private fun configure(pubKey: String, bearerToken: String?, promise: Promise) { + val context = reactContext ?: run { + promise.reject("E_INIT_FAILED", "React context is not available", null) + return + } + coroutineScope.launch { try { - publishableKey = pubKey - if (!Clerk.isInitialized.value) { // First-time initialization — write the bearer token to SharedPreferences // before initializing so the SDK boots with the correct client. if (!bearerToken.isNullOrEmpty()) { - reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) + context.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) .edit() .putString("DEVICE_TOKEN", bearerToken) .apply() } - Clerk.initialize(reactApplicationContext, pubKey) + Clerk.initialize(context, pubKey) + startClientStateObserver() // clerk-android registers ActivityLifecycleCallbacks during // initialize(), but in React Native MainActivity has already passed // onResume() by the time mounts and we reach this // line, so the callbacks miss the initial activity. Without seeding, // the first Credential Manager call (Google sign-in / passkeys) // fails with MissingActivity until the user backgrounds and - // foregrounds the app. getCurrentActivity() can be null here on + // foregrounds the app. currentActivity can be null here on // cold start before React's host-resume sync — AuthView and // UserProfile also call attachActivity() on mount as a backstop. - getCurrentActivity()?.let { Clerk.attachActivity(it) } - // Theme loading is centralized here. ClerkViewFactory.configure() - // and ClerkUserProfileActivity.onCreate() only call Clerk.initialize() - // when Clerk is not yet initialized, so by the time they run - // ClerkExpoModule has already set the custom theme. + appContext.currentActivity?.let { Clerk.attachActivity(it) } // Must be set AFTER Clerk.initialize() because initialize() // resets customTheme to its `theme` parameter (default null). - loadThemeFromAssets() + loadThemeFromAssets(context) // Wait for initialization to complete with timeout try { @@ -122,22 +156,72 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } else { "Clerk initialization timed out after 10 seconds" } - promise.reject("E_TIMEOUT", message) + promise.reject("E_TIMEOUT", message, null) return@launch } // Check for initialization errors val error = Clerk.initializationError.value if (error != null) { - promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}") + promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}", null) } else { + configuredPublishableKey = pubKey promise.resolve(null) } return@launch } + val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey + if (activePublishableKey != null && activePublishableKey != pubKey) { + Clerk.switchConfiguration(context, pubKey) + startClientStateObserver() + appContext.currentActivity?.let { Clerk.attachActivity(it) } + loadThemeFromAssets(context) + + try { + withTimeout(10_000L) { + Clerk.isInitialized.first { it } + } + } catch (e: TimeoutCancellationException) { + val initError = Clerk.initializationError.value + val message = if (initError != null) { + "Clerk reconfiguration timed out: ${initError.message}" + } else { + "Clerk reconfiguration timed out after 10 seconds" + } + promise.reject("E_TIMEOUT", message, null) + return@launch + } + + val error = Clerk.initializationError.value + if (error != null) { + promise.reject("E_RECONFIGURE_FAILED", "Failed to reconfigure Clerk SDK: ${error.message}", null) + return@launch + } + + if (!bearerToken.isNullOrEmpty()) { + val result = Clerk.updateDeviceToken(bearerToken) + if (result is ClerkResult.Failure) { + debugLog(TAG, "configure - updateDeviceToken after reconfigure failed: ${result.error}") + } + + try { + withTimeout(5_000L) { + Clerk.sessionFlow.first { it != null } + } + } catch (_: TimeoutCancellationException) { + debugLog(TAG, "configure - session did not appear after reconfigure token update") + } + } + + configuredPublishableKey = pubKey + promise.resolve(null) + return@launch + } + // Already initialized — use the public SDK API to update // the device token and trigger a client/environment refresh. + startClientStateObserver() if (!bearerToken.isNullOrEmpty()) { val result = Clerk.updateDeviceToken(bearerToken) if (result is ClerkResult.Failure) { @@ -161,71 +245,9 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } } - // MARK: - presentAuth - - @ReactMethod - override fun presentAuth(options: ReadableMap, promise: Promise) { - val activity = getCurrentActivity() ?: run { - promise.reject("E_ACTIVITY_UNAVAILABLE", "No activity available to present Clerk UI.") - return - } - - if (!Clerk.isInitialized.value) { - promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") - return - } - - // Check if user is already signed in - if (Clerk.session != null) { - promise.reject("already_signed_in", "User is already signed in") - return - } - - pendingAuthPromise?.reject("E_SUPERSEDED", "Auth presentation was superseded") - pendingAuthPromise = promise - - val mode = if (options.hasKey("mode")) options.getString("mode") ?: "signInOrUp" else "signInOrUp" - val dismissable = if (options.hasKey("dismissable")) options.getBoolean("dismissable") else true - - val intent = Intent(activity, ClerkAuthActivity::class.java).apply { - putExtra(EXTRA_MODE, mode) - putExtra(EXTRA_DISMISSABLE, dismissable) - } - - activity.startActivityForResult(intent, CLERK_AUTH_REQUEST_CODE) - } - - // MARK: - presentUserProfile - - @ReactMethod - override fun presentUserProfile(options: ReadableMap, promise: Promise) { - val activity = getCurrentActivity() ?: run { - promise.reject("E_ACTIVITY_UNAVAILABLE", "No activity available to present Clerk UI.") - return - } - - if (!Clerk.isInitialized.value) { - promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") - return - } - - pendingProfilePromise?.reject("E_SUPERSEDED", "Profile presentation was superseded") - pendingProfilePromise = promise - - val dismissable = if (options.hasKey("dismissable")) options.getBoolean("dismissable") else true - - val intent = Intent(activity, ClerkUserProfileActivity::class.java).apply { - putExtra(EXTRA_DISMISSABLE, dismissable) - putExtra(EXTRA_PUBLISHABLE_KEY, publishableKey) - } - - activity.startActivityForResult(intent, CLERK_PROFILE_REQUEST_CODE) - } - // MARK: - getSession - @ReactMethod - override fun getSession(promise: Promise) { + private fun getSession(promise: Promise) { if (!Clerk.isInitialized.value) { // Return null when not initialized (matches iOS behavior) // so callers can proceed to call configure() with a bearer token. @@ -236,28 +258,28 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : val session = Clerk.session val user = Clerk.user - val result = WritableNativeMap() + val result = mutableMapOf() session?.let { - val sessionMap = WritableNativeMap() - sessionMap.putString("id", it.id) - sessionMap.putString("status", it.status.name) - sessionMap.putString("userId", it.user?.id) - result.putMap("session", sessionMap) + result["session"] = mapOf( + "id" to it.id, + "status" to it.status.name, + "userId" to it.user?.id + ) } user?.let { val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId } val primaryPhone = it.phoneNumbers.find { p -> p.id == it.primaryPhoneNumberId } - val userMap = WritableNativeMap() - userMap.putString("id", it.id) - userMap.putString("firstName", it.firstName) - userMap.putString("lastName", it.lastName) - userMap.putString("imageUrl", it.imageUrl) - userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress) - userMap.putString("primaryPhoneNumber", primaryPhone?.phoneNumber) - result.putMap("user", userMap) + result["user"] = mapOf( + "id" to it.id, + "firstName" to it.firstName, + "lastName" to it.lastName, + "imageUrl" to it.imageUrl, + "primaryEmailAddress" to primaryEmail?.emailAddress, + "primaryPhoneNumber" to primaryPhone?.phoneNumber + ) } promise.resolve(result) @@ -265,8 +287,7 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : // MARK: - getClientToken - @ReactMethod - override fun getClientToken(promise: Promise) { + private fun getClientToken(promise: Promise) { try { // Use the SDK's public API which handles encrypted storage transparently. // Direct SharedPreferences reads break on clerk-android >= 1.0.11 where @@ -279,127 +300,35 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } } - // MARK: - signOut + // MARK: - refreshClient - @ReactMethod - override fun signOut(promise: Promise) { + private fun refreshClient(promise: Promise) { if (!Clerk.isInitialized.value) { - // Clear DEVICE_TOKEN from SharedPreferences even when not initialized, - // so the next Clerk.initialize() doesn't boot with a stale client token. - reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) - .edit() - .remove("DEVICE_TOKEN") - .apply() promise.resolve(null) return } coroutineScope.launch { try { - Clerk.auth.signOut() - // Client refresh after sign-out is handled by the clerk-android - // SDK (SignOutService.signOut calls Client.getSkippingClientId). - promise.resolve(null) + when (val result = Clerk.refreshClient()) { + is ClerkResult.Failure -> promise.reject( + "E_REFRESH_CLIENT_FAILED", + result.error?.firstMessage() ?: result.throwable?.message ?: "Client refresh failed", + null + ) + is ClerkResult.Success -> promise.resolve(null) + } } catch (e: Exception) { - promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign out failed", e) - } - } - } - - // MARK: - Activity Result Handling - - override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) { - when (requestCode) { - CLERK_AUTH_REQUEST_CODE -> handleAuthResult(resultCode, data) - CLERK_PROFILE_REQUEST_CODE -> handleProfileResult(resultCode, data) - } - } - - override fun onNewIntent(intent: Intent) { - // Not used - } - - private fun handleAuthResult(resultCode: Int, data: Intent?) { - val promise = pendingAuthPromise ?: return - pendingAuthPromise = null - - if (resultCode == Activity.RESULT_OK) { - val session = Clerk.session - val user = Clerk.user - - val result = WritableNativeMap() - - // Top-level sessionId for JS SDK compatibility (matches iOS response format) - result.putString("sessionId", session?.id) - - session?.let { - val sessionMap = WritableNativeMap() - sessionMap.putString("id", it.id) - sessionMap.putString("status", it.status.name) - sessionMap.putString("userId", it.user?.id) - result.putMap("session", sessionMap) - } - - user?.let { - val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId } - - val userMap = WritableNativeMap() - userMap.putString("id", it.id) - userMap.putString("firstName", it.firstName) - userMap.putString("lastName", it.lastName) - userMap.putString("imageUrl", it.imageUrl) - userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress) - result.putMap("user", userMap) + promise.reject("E_REFRESH_CLIENT_FAILED", e.message ?: "Client refresh failed", e) } - - promise.resolve(result) - } else { - val result = WritableNativeMap() - result.putBoolean("cancelled", true) - promise.resolve(result) - } - } - - private fun handleProfileResult(resultCode: Int, data: Intent?) { - val promise = pendingProfilePromise ?: return - pendingProfilePromise = null - - // Profile always returns current session state - val session = Clerk.session - val user = Clerk.user - - val result = WritableNativeMap() - - session?.let { - val sessionMap = WritableNativeMap() - sessionMap.putString("id", it.id) - sessionMap.putString("status", it.status.name) - sessionMap.putString("userId", it.user?.id) - result.putMap("session", sessionMap) } - - user?.let { - val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId } - - val userMap = WritableNativeMap() - userMap.putString("id", it.id) - userMap.putString("firstName", it.firstName) - userMap.putString("lastName", it.lastName) - userMap.putString("imageUrl", it.imageUrl) - userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress) - result.putMap("user", userMap) - } - - result.putBoolean("dismissed", resultCode == Activity.RESULT_CANCELED) - - promise.resolve(result) } // MARK: - Theme Loading - private fun loadThemeFromAssets() { + private fun loadThemeFromAssets(context: Context) { try { - val jsonString = reactApplicationContext.assets + val jsonString = context.assets .open("clerk_theme.json") .bufferedReader() .use { it.readText() } @@ -470,7 +399,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } private fun JSONObject.optStringColor(key: String): Color? { - val value = optString(key, null) ?: return null + if (!has(key) || isNull(key)) return null + val value = optString(key) return parseHexColor(value) } } diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt deleted file mode 100644 index 9a97309ac5e..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt +++ /dev/null @@ -1,43 +0,0 @@ -package expo.modules.clerk - -import com.facebook.react.TurboReactPackage -import com.facebook.react.bridge.NativeModule -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.module.model.ReactModuleInfo -import com.facebook.react.module.model.ReactModuleInfoProvider -import com.facebook.react.uimanager.ViewManager - -class ClerkPackage : TurboReactPackage() { - - override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { - return when (name) { - NativeClerkModuleSpec.NAME -> ClerkExpoModule(reactContext) - NativeClerkGoogleSignInSpec.NAME -> expo.modules.clerk.googlesignin.ClerkGoogleSignInModule(reactContext) - else -> null - } - } - - override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { - return ReactModuleInfoProvider { - mapOf( - NativeClerkModuleSpec.NAME to ReactModuleInfo( - NativeClerkModuleSpec.NAME, - ClerkExpoModule::class.java.name, - false, false, true, false, true - ), - NativeClerkGoogleSignInSpec.NAME to ReactModuleInfo( - NativeClerkGoogleSignInSpec.NAME, - expo.modules.clerk.googlesignin.ClerkGoogleSignInModule::class.java.name, - false, false, true, false, true - ), - ) - } - } - - override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return listOf( - ClerkAuthViewManager(), - ClerkUserProfileViewManager(), - ) - } -} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt new file mode 100644 index 00000000000..6ad219d65fc --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt @@ -0,0 +1,42 @@ +package expo.modules.clerk + +import android.content.Context +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.clerk.api.Clerk +import com.clerk.ui.userbutton.UserButton +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) { + init { + activity?.let { Clerk.attachActivity(it) } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + setupView() + } + + @Composable + override fun Content() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + UserButton(clerkTheme = Clerk.customTheme) + } + } +} + +class ClerkUserButtonViewModule : Module() { + override fun definition() = ModuleDefinition { + Name("ClerkUserButtonView") + + View(ClerkUserButtonNativeView::class) {} + } +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt deleted file mode 100644 index f68b4e30bd8..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt +++ /dev/null @@ -1,130 +0,0 @@ -package expo.modules.clerk - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.clerk.api.Clerk -import com.clerk.api.network.model.client.Client -import com.clerk.ui.userprofile.UserProfileView - -/** - * Activity that hosts the Clerk UserProfileView composable. - * Presents the native user profile UI and returns the result when dismissed. - */ -class ClerkUserProfileActivity : ComponentActivity() { - - companion object { - private const val TAG = "ClerkUserProfileActivity" - - private fun debugLog(tag: String, message: String) { - if (BuildConfig.DEBUG) { - Log.d(tag, message) - } - } - } - - private var dismissed = false - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - - val dismissable = intent.getBooleanExtra(ClerkExpoModule.EXTRA_DISMISSABLE, true) - val publishableKey = intent.getStringExtra(ClerkExpoModule.EXTRA_PUBLISHABLE_KEY) - - debugLog(TAG, "onCreate - isInitialized: ${Clerk.isInitialized.value}") - debugLog(TAG, "onCreate - hasSession: ${Clerk.session != null}, hasUser: ${Clerk.user != null}") - - // Initialize Clerk if not already initialized - if (publishableKey != null && !Clerk.isInitialized.value) { - debugLog(TAG, "Initializing Clerk...") - Clerk.initialize(applicationContext, publishableKey) - } - - setContent { - // Observe user state changes - val user by Clerk.userFlow.collectAsStateWithLifecycle() - val session by Clerk.sessionFlow.collectAsStateWithLifecycle() - - // Track if we had a session when the profile opened (to detect sign-out) - var hadSession by remember { mutableStateOf(Clerk.session != null) } - - // Log when user/session state changes - LaunchedEffect(user, session) { - debugLog(TAG, "State changed - hasSession: ${session != null}, hasUser: ${user != null}") - } - - // Detect sign-out: if we had a session and now it's null, user signed out - LaunchedEffect(session) { - if (hadSession && session == null) { - debugLog(TAG, "Sign-out detected - session became null") - // Fetch a brand-new client from the server, skipping the in-memory - // client_id header. Without skipping, the server echoes back the SAME - // client (with the previous user's in-progress signIn still attached), - // and the AuthView re-mounts into the "Get help" fallback because the - // stale signIn's status has no startingFirstFactor. - try { - Client.getSkippingClientId() - } catch (e: Exception) { - Log.w(TAG, "Client.getSkippingClientId() after UserProfile sign-out failed: ${e.message}") - } - finishWithSuccess() - } - // Update hadSession if we get a session (handles edge cases) - if (session != null) { - hadSession = true - } - } - - MaterialTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - UserProfileView( - clerkTheme = Clerk.customTheme, - onDismiss = { - finishWithSuccess() - } - ) - } - } - } - - // Handle back press via onBackPressedDispatcher (replaces deprecated onBackPressed) - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (dismissable) { - finishWithSuccess() - } - // Otherwise ignore back press - } - }) - } - - private fun finishWithSuccess() { - if (dismissed) return - dismissed = true - - val result = Intent() - result.putExtra(ClerkExpoModule.RESULT_SESSION_ID, Clerk.session?.id) - result.putExtra(ClerkExpoModule.RESULT_CANCELLED, false) - setResult(Activity.RESULT_OK, result) - finish() - } -} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt deleted file mode 100644 index 8d3762a3be6..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt +++ /dev/null @@ -1,144 +0,0 @@ -package expo.modules.clerk - -import android.content.Context -import android.util.Log -import android.widget.FrameLayout -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Recomposer -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.AndroidUiDispatcher -import androidx.compose.ui.platform.ComposeView -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.setViewTreeLifecycleOwner -import androidx.lifecycle.setViewTreeViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.savedstate.compose.LocalSavedStateRegistryOwner -import androidx.savedstate.setViewTreeSavedStateRegistryOwner -import com.clerk.api.Clerk -import com.clerk.api.network.model.client.Client -import com.clerk.ui.userprofile.UserProfileView -import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.ReactContext -import com.facebook.react.uimanager.events.RCTEventEmitter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -private const val TAG = "ClerkUserProfileExpoView" - -class ClerkUserProfileNativeView(context: Context) : FrameLayout(context) { - var isDismissable: Boolean = true - - private val activity = ClerkAuthNativeView.findActivity(context) - - private var recomposer: Recomposer? = null - private var recomposerJob: kotlinx.coroutines.Job? = null - - private val composeView = ComposeView(context).also { view -> - activity?.let { act -> - view.setViewTreeLifecycleOwner(act) - view.setViewTreeViewModelStoreOwner(act) - view.setViewTreeSavedStateRegistryOwner(act) - - val recomposerContext = AndroidUiDispatcher.Main - val newRecomposer = Recomposer(recomposerContext) - recomposer = newRecomposer - view.setParentCompositionContext(newRecomposer) - val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob()) - recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job] - scope.launch { - newRecomposer.runRecomposeAndApplyChanges() - } - } - addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) - } - - override fun onDetachedFromWindow() { - recomposer?.cancel() - recomposerJob?.cancel() - super.onDetachedFromWindow() - } - - fun setupView() { - Log.d(TAG, "setupView - isDismissable: $isDismissable") - - composeView.setContent { - val session by Clerk.sessionFlow.collectAsStateWithLifecycle() - - var hadSession by remember { mutableStateOf(Clerk.session != null) } - - LaunchedEffect(session) { - if (hadSession && session == null) { - Log.d(TAG, "Sign-out detected") - // Refresh the client from the server to clear any stale in-progress - // signIn/signUp state. Without this, when the AuthView re-mounts after - // sign-out it routes to the "Get help" fallback because the previous - // user's signIn is still in Clerk.client. Clerk.auth.signOut() (called - // internally by UserProfileView) only clears session/user state, not - // the in-progress signIn. - try { - Client.getSkippingClientId() - } catch (e: Exception) { - Log.w(TAG, "Client.getSkippingClientId() after UserProfile sign-out failed: ${e.message}") - } - sendEvent("signedOut", emptyMap()) - } - if (session != null) { - hadSession = true - } - } - - val content = @androidx.compose.runtime.Composable { - MaterialTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - UserProfileView( - clerkTheme = Clerk.customTheme, - onDismiss = { - Log.d(TAG, "Profile dismissed") - sendEvent("dismissed", emptyMap()) - } - ) - } - } - } - - if (activity != null) { - CompositionLocalProvider( - LocalViewModelStoreOwner provides activity, - LocalLifecycleOwner provides activity, - LocalSavedStateRegistryOwner provides activity, - ) { - content() - } - } else { - content() - } - } - } - - private fun sendEvent(type: String, data: Map) { - val reactContext = context as? ReactContext ?: return - val eventData = Arguments.createMap().apply { - putString("type", type) - val jsonString = try { - org.json.JSONObject(data).toString() - } catch (e: Exception) { - "{}" - } - putString("data", jsonString) - } - reactContext.getJSModule(RCTEventEmitter::class.java) - .receiveEvent(id, "onProfileEvent", eventData) - } -} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt deleted file mode 100644 index bc5a338271e..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt +++ /dev/null @@ -1,32 +0,0 @@ -package expo.modules.clerk - -import com.facebook.react.common.MapBuilder -import com.facebook.react.uimanager.SimpleViewManager -import com.facebook.react.uimanager.ThemedReactContext -import com.facebook.react.uimanager.annotations.ReactProp -import com.facebook.react.viewmanagers.ClerkUserProfileViewManagerInterface - -class ClerkUserProfileViewManager : SimpleViewManager(), - ClerkUserProfileViewManagerInterface { - - override fun getName(): String = "ClerkUserProfileView" - - override fun createViewInstance(reactContext: ThemedReactContext): ClerkUserProfileNativeView { - return ClerkUserProfileNativeView(reactContext) - } - - @ReactProp(name = "isDismissable") - override fun setIsDismissable(view: ClerkUserProfileNativeView, isDismissable: Boolean) { - view.isDismissable = isDismissable - view.setupView() - } - - override fun getExportedCustomBubblingEventTypeConstants(): MutableMap? { - return MapBuilder.builder() - .put("onProfileEvent", MapBuilder.of( - "phasedRegistrationNames", - MapBuilder.of("bubbled", "onProfileEvent") - )) - .build() as MutableMap - } -} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt new file mode 100644 index 00000000000..aa87e40853b --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt @@ -0,0 +1,78 @@ +package expo.modules.clerk + +import android.content.Context +import android.util.Log +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import com.clerk.api.Clerk +import com.clerk.ui.userprofile.UserProfileView +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.viewevent.EventDispatcher + +private const val TAG = "ClerkUserProfileViewModule" + +private fun debugLog(tag: String, message: String) { + if (BuildConfig.DEBUG) { + Log.d(tag, message) + } +} + +class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) { + // clerk-android UserProfileView dismissibility is controlled by its onDismiss callback. + var isDismissible: Boolean = true + private val onProfileEvent by EventDispatcher() + + private val viewModelStoreOwner = object : ViewModelStoreOwner { + private val store = ViewModelStore() + override val viewModelStore: ViewModelStore = store + } + + override fun localViewModelStoreOwner(): ViewModelStoreOwner = viewModelStoreOwner + + override fun onHostDetachedFromWindow() { + viewModelStoreOwner.viewModelStore.clear() + } + + @Composable + override fun Content() { + debugLog(TAG, "setupView - isDismissible: $isDismissible") + + UserProfileView( + clerkTheme = Clerk.customTheme, + onDismiss = { + debugLog(TAG, "Profile dismissed") + sendEvent("dismissed") + } + ) + } + + private fun sendEvent(type: String) { + onProfileEvent(mapOf("type" to type)) + } +} + +class ClerkUserProfileViewModule : Module() { + override fun definition() = ModuleDefinition { + Name("ClerkUserProfileView") + + View(ClerkUserProfileNativeView::class) { + Events("onProfileEvent") + + Prop("isDismissible") { view: ClerkUserProfileNativeView, isDismissible: Boolean -> + // clerk-android does not expose the iOS-parity isDismissible API yet. + // Accept the prop for cross-platform RN API shape, and pass it through + // once the native SDK owns this behavior. + view.isDismissible = isDismissible + } + + OnViewDidUpdateProps { view: ClerkUserProfileNativeView -> + view.setupView() + } + } + } +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt deleted file mode 100644 index e77ad21ddf0..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt +++ /dev/null @@ -1,102 +0,0 @@ -package expo.modules.clerk - -import android.content.Context -import android.content.Intent -import com.clerk.api.Clerk -import com.clerk.api.network.serialization.ClerkResult -import kotlinx.coroutines.flow.first - -/** - * Implementation of ClerkViewFactoryInterface. - * Provides Clerk SDK operations and creates intents for auth/profile activities. - */ -class ClerkViewFactory : ClerkViewFactoryInterface { - - // Store the publishable key for later use - private var storedPublishableKey: String? = null - private var storedContext: Context? = null - - override suspend fun configure(context: Context, publishableKey: String) { - println("[ClerkViewFactory] Configuring Clerk with publishable key: ${publishableKey.take(20)}...") - - // Store for later use - storedPublishableKey = publishableKey - storedContext = context.applicationContext - - // Initialize Clerk if not already initialized - if (!Clerk.isInitialized.value) { - Clerk.initialize(context.applicationContext, publishableKey) - - // Wait for initialization to complete - Clerk.isInitialized.first { it } - println("[ClerkViewFactory] Clerk initialized successfully") - } else { - println("[ClerkViewFactory] Clerk already initialized") - } - } - - override fun createAuthIntent(context: Context, mode: String, dismissable: Boolean): Intent { - return Intent(context, ClerkAuthActivity::class.java).apply { - putExtra(ClerkExpoModule.EXTRA_MODE, mode) - putExtra(ClerkExpoModule.EXTRA_DISMISSABLE, dismissable) - storedPublishableKey?.let { putExtra(ClerkExpoModule.EXTRA_PUBLISHABLE_KEY, it) } - } - } - - override fun createUserProfileIntent(context: Context, dismissable: Boolean): Intent { - return Intent(context, ClerkUserProfileActivity::class.java).apply { - putExtra(ClerkExpoModule.EXTRA_DISMISSABLE, dismissable) - storedPublishableKey?.let { putExtra(ClerkExpoModule.EXTRA_PUBLISHABLE_KEY, it) } - } - } - - override suspend fun getSession(): Map? { - val session = Clerk.session ?: return null - val user = Clerk.user ?: return null - - return mapOf( - "sessionId" to session.id, - "userId" to user.id, - "user" to mapOf( - "id" to user.id, - "firstName" to user.firstName, - "lastName" to user.lastName, - "fullName" to "${user.firstName ?: ""} ${user.lastName ?: ""}".trim().ifEmpty { null }, - "username" to user.username, - "imageUrl" to user.imageUrl, - "primaryEmailAddress" to user.primaryEmailAddress?.emailAddress, - "primaryPhoneNumber" to user.primaryPhoneNumber?.phoneNumber, - "createdAt" to user.createdAt, - "updatedAt" to user.updatedAt, - ) - ) - } - - override suspend fun signOut() { - val result = Clerk.auth.signOut() - when (result) { - is ClerkResult.Success -> { - println("[ClerkViewFactory] Sign out successful") - } - is ClerkResult.Failure -> { - println("[ClerkViewFactory] Sign out failed: ${result.error}") - throw Exception("Sign out failed: ${result.error}") - } - } - } - - override fun isInitialized(): Boolean { - return Clerk.isInitialized.value - } - - companion object { - /** - * Initialize the ClerkViewFactory and register it globally. - * Call this from your Application.onCreate() or MainActivity.onCreate() - */ - fun initialize() { - ClerkViewFactoryRegistry.factory = ClerkViewFactory() - println("[ClerkViewFactory] Factory registered") - } - } -} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt deleted file mode 100644 index 7b82bd1ec20..00000000000 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt +++ /dev/null @@ -1,52 +0,0 @@ -package expo.modules.clerk - -import android.content.Context -import android.content.Intent - -/** - * Interface for providing Clerk views and SDK operations. - * This mirrors the iOS ClerkViewFactoryProtocol pattern. - */ -interface ClerkViewFactoryInterface { - /** - * Configure the Clerk SDK with the publishable key. - */ - suspend fun configure(context: Context, publishableKey: String) - - /** - * Create an Intent to launch the authentication activity. - * @param mode The auth mode: "signIn", "signUp", or "signInOrUp" - * @param dismissable Whether the user can dismiss the modal - */ - fun createAuthIntent(context: Context, mode: String, dismissable: Boolean): Intent - - /** - * Create an Intent to launch the user profile activity. - * @param dismissable Whether the user can dismiss the modal - */ - fun createUserProfileIntent(context: Context, dismissable: Boolean): Intent - - /** - * Get the current session data as a Map for JS. - * Returns null if no session is active. - */ - suspend fun getSession(): Map? - - /** - * Sign out the current user. - */ - suspend fun signOut() - - /** - * Check if the SDK is initialized. - */ - fun isInitialized(): Boolean -} - -/** - * Global registry for the Clerk view factory. - * Set by the app target at startup (similar to iOS pattern). - */ -object ClerkViewFactoryRegistry { - var factory: ClerkViewFactoryInterface? = null -} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt index 54183ce5552..68be6942cf4 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt @@ -1,6 +1,5 @@ package expo.modules.clerk.googlesignin -import android.content.Context import androidx.credentials.ClearCredentialStateRequest import androidx.credentials.CredentialManager import androidx.credentials.CustomCredential @@ -9,64 +8,75 @@ import androidx.credentials.GetCredentialResponse import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.NoCredentialException -import com.facebook.react.bridge.Promise -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.ReactMethod -import expo.modules.clerk.NativeClerkGoogleSignInSpec -import com.facebook.react.bridge.ReadableMap -import com.facebook.react.bridge.WritableNativeMap import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException +import expo.modules.kotlin.Promise +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -class ClerkGoogleSignInModule(reactContext: ReactApplicationContext) : - NativeClerkGoogleSignInSpec(reactContext) { - +class ClerkGoogleSignInModule : Module() { private var webClientId: String? = null private var hostedDomain: String? = null private var autoSelectEnabled: Boolean = false private val mainScope = CoroutineScope(Dispatchers.Main) private val credentialManager: CredentialManager - get() = CredentialManager.create(reactApplicationContext) + get() = CredentialManager.create(requireNotNull(appContext.reactContext)) + + override fun definition() = ModuleDefinition { + Name("ClerkGoogleSignIn") + + Function("configure") { params: Map -> + configure(params) + } - override fun getName(): String = "ClerkGoogleSignIn" + AsyncFunction("signIn") { params: Map?, promise: Promise -> + signIn(params, promise) + } + + AsyncFunction("createAccount") { params: Map?, promise: Promise -> + createAccount(params, promise) + } + + AsyncFunction("presentExplicitSignIn") { params: Map?, promise: Promise -> + presentExplicitSignIn(params, promise) + } + + AsyncFunction("signOut") { promise: Promise -> + signOut(promise) + } + } // MARK: - configure - @ReactMethod - override fun configure(params: ReadableMap) { - webClientId = if (params.hasKey("webClientId")) params.getString("webClientId") else null - hostedDomain = if (params.hasKey("hostedDomain")) params.getString("hostedDomain") else null - autoSelectEnabled = if (params.hasKey("autoSelectEnabled")) params.getBoolean("autoSelectEnabled") else false + private fun configure(params: Map) { + webClientId = params["webClientId"] as? String + hostedDomain = params["hostedDomain"] as? String + autoSelectEnabled = params["autoSelectEnabled"] as? Boolean ?: false } // MARK: - signIn - @ReactMethod - override fun signIn(params: ReadableMap?, promise: Promise) { + private fun signIn(params: Map?, promise: Promise) { val clientId = webClientId ?: run { - promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.") + promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", null) return } - val activity = getCurrentActivity() ?: run { - promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available") + val activity = appContext.currentActivity ?: run { + promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available", null) return } mainScope.launch { try { - val filterByAuthorized = params?.let { - if (it.hasKey("filterByAuthorizedAccounts")) it.getBoolean("filterByAuthorizedAccounts") else true - } ?: true - val nonce = params?.let { - if (it.hasKey("nonce")) it.getString("nonce") else null - } + val filterByAuthorized = params?.get("filterByAuthorizedAccounts") as? Boolean ?: true + val nonce = params?.get("nonce") as? String val googleIdOption = GetGoogleIdOption.Builder() .setFilterByAuthorizedAccounts(filterByAuthorized) @@ -101,23 +111,20 @@ class ClerkGoogleSignInModule(reactContext: ReactApplicationContext) : // MARK: - createAccount - @ReactMethod - override fun createAccount(params: ReadableMap?, promise: Promise) { + private fun createAccount(params: Map?, promise: Promise) { val clientId = webClientId ?: run { - promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.") + promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", null) return } - val activity = getCurrentActivity() ?: run { - promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available") + val activity = appContext.currentActivity ?: run { + promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available", null) return } mainScope.launch { try { - val nonce = params?.let { - if (it.hasKey("nonce")) it.getString("nonce") else null - } + val nonce = params?.get("nonce") as? String val googleIdOption = GetGoogleIdOption.Builder() .setFilterByAuthorizedAccounts(false) // Show all accounts for creation @@ -151,23 +158,20 @@ class ClerkGoogleSignInModule(reactContext: ReactApplicationContext) : // MARK: - presentExplicitSignIn - @ReactMethod - override fun presentExplicitSignIn(params: ReadableMap?, promise: Promise) { + private fun presentExplicitSignIn(params: Map?, promise: Promise) { val clientId = webClientId ?: run { - promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.") + promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", null) return } - val activity = getCurrentActivity() ?: run { - promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available") + val activity = appContext.currentActivity ?: run { + promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available", null) return } mainScope.launch { try { - val nonce = params?.let { - if (it.hasKey("nonce")) it.getString("nonce") else null - } + val nonce = params?.get("nonce") as? String val signInWithGoogleOption = GetSignInWithGoogleOption.Builder(clientId) .apply { @@ -198,8 +202,7 @@ class ClerkGoogleSignInModule(reactContext: ReactApplicationContext) : // MARK: - signOut - @ReactMethod - override fun signOut(promise: Promise) { + private fun signOut(promise: Promise) { mainScope.launch { try { credentialManager.clearCredentialState(ClearCredentialStateRequest()) @@ -219,35 +222,35 @@ class ClerkGoogleSignInModule(reactContext: ReactApplicationContext) : try { val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) - val userMap = WritableNativeMap().apply { - putString("id", googleIdTokenCredential.id) - putString("email", googleIdTokenCredential.id) - putString("name", googleIdTokenCredential.displayName) - putString("givenName", googleIdTokenCredential.givenName) - putString("familyName", googleIdTokenCredential.familyName) - putString("photo", googleIdTokenCredential.profilePictureUri?.toString()) - } - - val dataMap = WritableNativeMap().apply { - putString("idToken", googleIdTokenCredential.idToken) - putMap("user", userMap) - } - - val responseMap = WritableNativeMap().apply { - putString("type", "success") - putMap("data", dataMap) - } - - promise.resolve(responseMap) + val user = mapOf( + "id" to googleIdTokenCredential.id, + "email" to googleIdTokenCredential.id, + "name" to googleIdTokenCredential.displayName, + "givenName" to googleIdTokenCredential.givenName, + "familyName" to googleIdTokenCredential.familyName, + "photo" to googleIdTokenCredential.profilePictureUri?.toString() + ) + + val data = mapOf( + "idToken" to googleIdTokenCredential.idToken, + "user" to user + ) + + promise.resolve( + mapOf( + "type" to "success", + "data" to data + ) + ) } catch (e: GoogleIdTokenParsingException) { promise.reject("GOOGLE_SIGN_IN_ERROR", "Failed to parse Google ID token: ${e.message}", e) } } else { - promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type: ${credential.type}") + promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type: ${credential.type}", null) } } else -> { - promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type") + promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type", null) } } } diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index e2bf4d163e9..26e0033b104 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -6,8 +6,8 @@ * 1. iOS is configured with Swift Package Manager dependency for clerk-ios * 2. Android is configured with packaging exclusions for dependencies * - * Native modules are registered via react-native.config.js and standard - * React Native autolinking (RCTViewManager / ReactPackage). + * Native modules are registered via Expo Modules autolinking on Android and + * React Native autolinking on iOS (RCTViewManager). */ const { withXcodeProject, @@ -214,7 +214,7 @@ const withClerkIOS = config => { return config; }); - // Inject ClerkViewFactory.register() call into AppDelegate.swift + // Inject ClerkNativeBridge.register() call into AppDelegate.swift config = withDangerousMod(config, [ 'ios', async config => { @@ -226,7 +226,7 @@ const withClerkIOS = config => { let contents = fs.readFileSync(appDelegatePath, 'utf8'); // Check if already added - if (!contents.includes('ClerkViewFactory.register()')) { + if (!contents.includes('ClerkNativeBridge.register()')) { // Find the didFinishLaunchingWithOptions method and add the registration call // Look for the return statement in didFinishLaunching const pattern = /(func application\s*\([^)]*didFinishLaunchingWithOptions[^)]*\)[^{]*\{)/; @@ -235,10 +235,10 @@ const withClerkIOS = config => { if (match) { // Insert after the opening brace of didFinishLaunching const insertPoint = match.index + match[0].length; - const registrationCode = '\n // Register Clerk native views\n ClerkViewFactory.register()\n'; + const registrationCode = '\n // Register Clerk native bridge\n ClerkNativeBridge.register()\n'; contents = contents.slice(0, insertPoint) + registrationCode + contents.slice(insertPoint); fs.writeFileSync(appDelegatePath, contents); - console.log('✅ Added ClerkViewFactory.register() to AppDelegate.swift'); + console.log('✅ Added ClerkNativeBridge.register() to AppDelegate.swift'); } else { console.warn('⚠️ Could not find didFinishLaunchingWithOptions in AppDelegate.swift'); } @@ -249,7 +249,7 @@ const withClerkIOS = config => { }, ]); - // Then inject ClerkViewFactory.swift into the app target + // Then inject ClerkNativeBridge.swift into the app target // This is required because the file uses `import ClerkKit` which is only available // via SPM in the app target (CocoaPods targets can't see SPM packages) config = withXcodeProject(config, config => { @@ -258,26 +258,26 @@ const withClerkIOS = config => { const projectName = config.modRequest.projectName; const iosProjectPath = path.join(platformProjectRoot, projectName); - // Find the ClerkViewFactory.swift source file using Node's module resolution, + // Find the ClerkNativeBridge.swift source file using Node's module resolution, // which handles arbitrary nesting depths in pnpm/yarn/npm workspaces. let sourceFile; try { const packageRoot = path.dirname(require.resolve('@clerk/expo/package.json')); - sourceFile = path.join(packageRoot, 'ios', 'ClerkViewFactory.swift'); + sourceFile = path.join(packageRoot, 'ios', 'ClerkNativeBridge.swift'); } catch { sourceFile = null; } if (sourceFile && fs.existsSync(sourceFile)) { // ALWAYS copy the file to ensure we have the latest version - const targetFile = path.join(iosProjectPath, 'ClerkViewFactory.swift'); + const targetFile = path.join(iosProjectPath, 'ClerkNativeBridge.swift'); fs.copyFileSync(sourceFile, targetFile); - console.log('✅ Copied ClerkViewFactory.swift to app target'); + console.log('✅ Copied ClerkNativeBridge.swift to app target'); // Add the file to the Xcode project manually const xcodeProject = config.modResults; - const relativePath = `${projectName}/ClerkViewFactory.swift`; - const fileName = 'ClerkViewFactory.swift'; + const relativePath = `${projectName}/ClerkNativeBridge.swift`; + const fileName = 'ClerkNativeBridge.swift'; try { // Get the main target @@ -295,7 +295,7 @@ const withClerkIOS = config => { if (alreadyExists) { // File is already in project, but we still copied the latest version - console.log('✅ ClerkViewFactory.swift updated in app target'); + console.log('✅ ClerkNativeBridge.swift updated in app target'); return config; } @@ -309,7 +309,7 @@ const withClerkIOS = config => { isa: 'PBXFileReference', lastKnownFileType: 'sourcecode.swift', name: fileName, - path: relativePath, // Use full relative path (projectName/ClerkViewFactory.swift) + path: relativePath, // Use full relative path (projectName/ClerkNativeBridge.swift) sourceTree: '""', }; @@ -379,16 +379,16 @@ const withClerkIOS = config => { console.warn('⚠️ Could not find main PBXGroup for project'); } - console.log('✅ Added ClerkViewFactory.swift to Xcode project'); + console.log('✅ Added ClerkNativeBridge.swift to Xcode project'); } catch (addError) { console.error('❌ Error adding file to Xcode project:', addError.message); console.error(addError.stack); } } else { - console.warn('⚠️ ClerkViewFactory.swift not found, skipping injection'); + console.warn('⚠️ ClerkNativeBridge.swift not found, skipping injection'); } } catch (error) { - console.error('❌ Error injecting ClerkViewFactory.swift:', error.message); + console.error('❌ Error injecting ClerkNativeBridge.swift:', error.message); } return config; @@ -551,8 +551,8 @@ const withClerkGoogleSignIn = config => { * 2. Android gets packaging exclusions for dependency conflicts * 3. Google Sign-In URL scheme is configured (if env var is set) * - * Native modules are registered via react-native.config.js and standard - * React Native autolinking (RCTViewManager / ReactPackage). + * Native modules are registered via Expo Modules autolinking on Android and + * React Native autolinking on iOS (RCTViewManager). */ /** * Write ClerkKeychainService to Info.plist when keychainService is provided. diff --git a/packages/expo/expo-module.config.json b/packages/expo/expo-module.config.json index 876f466b1ad..5ed082ea351 100644 --- a/packages/expo/expo-module.config.json +++ b/packages/expo/expo-module.config.json @@ -1,3 +1,12 @@ { - "platforms": ["apple"] + "platforms": ["apple", "android"], + "android": { + "modules": [ + "expo.modules.clerk.ClerkExpoModule", + "expo.modules.clerk.ClerkAuthViewModule", + "expo.modules.clerk.ClerkUserProfileViewModule", + "expo.modules.clerk.ClerkUserButtonViewModule", + "expo.modules.clerk.googlesignin.ClerkGoogleSignInModule" + ] + } } diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo/ios/ClerkAuthNativeView.swift new file mode 100644 index 00000000000..601d3e5252f --- /dev/null +++ b/packages/expo/ios/ClerkAuthNativeView.swift @@ -0,0 +1,73 @@ +import React +import UIKit + +public class ClerkAuthNativeView: ClerkNativeViewHost { + private var currentMode: String = "signInOrUp" + private var currentDismissible: Bool = true + private var didSendDismiss = false + + @objc var onAuthEvent: RCTBubblingEventBlock? + + @objc var mode: NSString? { + didSet { + let newMode = (mode as String?) ?? "signInOrUp" + guard newMode != currentMode else { return } + currentMode = newMode + setNeedsHostedViewUpdate() + } + } + + @objc var isDismissible: NSNumber? { + didSet { + let newDismissible = isDismissible?.boolValue ?? true + guard newDismissible != currentDismissible else { return } + currentDismissible = newDismissible + setNeedsHostedViewUpdate() + } + } + + private func sendAuthEvent(type: ClerkNativeViewEvent) { + onAuthEvent?(["type": type.rawValue]) + } + + private func sendDismissIfNeeded() { + // SwiftUI dismissals detach the hosted view without calling UIKit dismiss(). + guard currentDismissible, !didSendDismiss else { return } + didSendDismiss = true + sendAuthEvent(type: .dismissed) + } + + override func hostedViewDidAttachToWindow() { + didSendDismiss = false + } + + override func hostedViewDidDetachFromWindow() { + sendDismissIfNeeded() + } + + override func makeHostedController() -> UIViewController? { + guard let bridge = clerkNativeBridge else { return nil } + + return bridge.makeAuthViewController( + mode: currentMode, + dismissible: currentDismissible, + onEvent: { [weak self] event, _ in + if event == .dismissed { + self?.sendDismissIfNeeded() + } + } + ) + } +} + +@objc(ClerkAuthViewManager) +class ClerkAuthViewManager: RCTViewManager { + + override static func requiresMainQueueSetup() -> Bool { + return true + } + + override func view() -> UIView! { + return ClerkAuthNativeView() + } +} diff --git a/packages/expo/ios/ClerkAuthViewManager.m b/packages/expo/ios/ClerkAuthViewManager.m index c5a25dd8a9b..87dbc97da29 100644 --- a/packages/expo/ios/ClerkAuthViewManager.m +++ b/packages/expo/ios/ClerkAuthViewManager.m @@ -3,7 +3,7 @@ @interface RCT_EXTERN_MODULE(ClerkAuthViewManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(mode, NSString) -RCT_EXPORT_VIEW_PROPERTY(isDismissable, NSNumber) +RCT_EXPORT_VIEW_PROPERTY(isDismissible, NSNumber) RCT_EXPORT_VIEW_PROPERTY(onAuthEvent, RCTBubblingEventBlock) @end diff --git a/packages/expo/ios/ClerkAuthViewManager.swift b/packages/expo/ios/ClerkAuthViewManager.swift deleted file mode 100644 index 0ab9629edba..00000000000 --- a/packages/expo/ios/ClerkAuthViewManager.swift +++ /dev/null @@ -1,13 +0,0 @@ -import React - -@objc(ClerkAuthViewManager) -class ClerkAuthViewManager: RCTViewManager { - - override static func requiresMainQueueSetup() -> Bool { - return true - } - - override func view() -> UIView! { - return ClerkAuthNativeView() - } -} diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec index fbd91f9a91c..d1d122982d2 100644 --- a/packages/expo/ios/ClerkExpo.podspec +++ b/packages/expo/ios/ClerkExpo.podspec @@ -36,11 +36,16 @@ Pod::Spec.new do |s| } # Only include the module files in the pod (both Swift and ObjC bridges). - # ClerkViewFactory.swift (with views) is injected into the app target by the config plugin + # ClerkNativeBridge.swift is injected into the app target by the config plugin # because it uses `import ClerkKit` which is only available via SPM in the app target. s.source_files = "ClerkExpoModule.swift", "ClerkExpoModule.m", - "ClerkAuthViewManager.swift", "ClerkAuthViewManager.m", - "ClerkUserProfileViewManager.swift", "ClerkUserProfileViewManager.m" + "ClerkNativeViewHost.swift", + "ClerkAuthNativeView.swift", + "ClerkAuthViewManager.m", + "ClerkUserProfileNativeView.swift", + "ClerkUserProfileViewManager.m", + "ClerkUserButtonNativeView.swift", + "ClerkUserButtonViewManager.m" install_modules_dependencies(s) end diff --git a/packages/expo/ios/ClerkExpoModule.m b/packages/expo/ios/ClerkExpoModule.m index febfe003c61..a0bb2e88f08 100644 --- a/packages/expo/ios/ClerkExpoModule.m +++ b/packages/expo/ios/ClerkExpoModule.m @@ -8,21 +8,13 @@ @interface RCT_EXTERN_MODULE(ClerkExpo, RCTEventEmitter) resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(presentAuth:(NSDictionary *)options - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(presentUserProfile:(NSDictionary *)options - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) - RCT_EXTERN_METHOD(getSession:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(getClientToken:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(signOut:(RCTPromiseResolveBlock)resolve +RCT_EXTERN_METHOD(refreshClient:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) @end diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index efd1e142445..462fef7e812 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -1,29 +1,32 @@ // ClerkExpoModule - Native module for Clerk integration -// This module provides the configure function and view presentation methods. -// Views are presented as modal view controllers (not embedded views) -// because the Clerk SDK (SPM) isn't accessible from CocoaPods. +// This module provides the configure function, session sync, and native view bridges. +// SwiftUI Clerk views are created by the app target through ClerkNativeBridge because +// the Clerk SDK (SPM) isn't accessible from the CocoaPods-backed React Native pod. import UIKit import React -// Global registry for the Clerk view factory (set by app target at startup) -public var clerkViewFactory: ClerkViewFactoryProtocol? +/// Events emitted by the native view wrappers to their React Native host views. +public enum ClerkNativeViewEvent: String { + /// Emitted by the Expo host view when app-owned dismissible content leaves the window. + case dismissed +} -// Protocol that the app target implements to provide Clerk views -public protocol ClerkViewFactoryProtocol { - // Modal presentation (existing) - func createAuthViewController(mode: String, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) -> UIViewController? - func createUserProfileViewController(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) -> UIViewController? +// Global registry for the app-target native bridge (set by the app target at startup) +public var clerkNativeBridge: ClerkNativeBridgeProtocol? +// Protocol that the app target implements to provide Clerk SDK operations and SwiftUI views. +public protocol ClerkNativeBridgeProtocol { // Inline rendering — returns UIViewController to preserve SwiftUI lifecycle - func createAuthView(mode: String, dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController? - func createUserProfileView(dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController? + func makeAuthViewController(mode: String, dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController? + func makeUserProfileViewController(dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController? + func makeUserButtonViewController() -> UIViewController? // SDK operations func configure(publishableKey: String, bearerToken: String?) async throws func getSession() async -> [String: Any]? func getClientToken() -> String? - func signOut() async throws + func refreshClient() async throws } // MARK: - Module @@ -44,7 +47,7 @@ class ClerkExpoModule: RCTEventEmitter { } override func supportedEvents() -> [String]! { - return ["onAuthStateChange"] + return ["refreshClient"] } override func startObserving() { @@ -55,30 +58,11 @@ class ClerkExpoModule: RCTEventEmitter { ClerkExpoModule._hasListeners = false } - /// Emits an onAuthStateChange event to JS from anywhere in the native layer. - /// Used by inline views (AuthView, UserProfileView) to notify ClerkProvider - /// of auth state changes in addition to the view-level onAuthEvent callback. - static func emitAuthStateChange(type: String, sessionId: String?) { + /// Emits a refreshClient event to JS from anywhere in the native layer. + /// Used by native views to ask ClerkProvider to reload JS client state. + static func emitRefreshClient() { guard _hasListeners, let instance = sharedInstance else { return } - instance.sendEvent(withName: "onAuthStateChange", body: [ - "type": type, - "sessionId": sessionId as Any, - ]) - } - - /// Returns the topmost presented view controller, avoiding deprecated `keyWindow`. - private static func topViewController() -> UIViewController? { - guard let scene = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .first(where: { $0.activationState == .foregroundActive }), - let rootVC = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController - else { return nil } - - var top = rootVC - while let presented = top.presentedViewController { - top = presented - } - return top + instance.sendEvent(withName: "refreshClient", body: nil) } // MARK: - configure @@ -87,14 +71,14 @@ class ClerkExpoModule: RCTEventEmitter { bearerToken: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - guard let factory = clerkViewFactory else { - reject("E_NOT_INITIALIZED", "Clerk not initialized. Make sure ClerkViewFactory is registered.", nil) + guard let bridge = clerkNativeBridge else { + reject("E_NOT_INITIALIZED", "Clerk not initialized. Make sure ClerkNativeBridge is registered.", nil) return } Task { do { - try await factory.configure(publishableKey: publishableKey, bearerToken: bearerToken) + try await bridge.configure(publishableKey: publishableKey, bearerToken: bearerToken) resolve(nil) } catch { reject("E_CONFIGURE_FAILED", error.localizedDescription, error) @@ -102,84 +86,17 @@ class ClerkExpoModule: RCTEventEmitter { } } - // MARK: - presentAuth - - @objc func presentAuth(_ options: NSDictionary, - resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { - guard let factory = clerkViewFactory else { - reject("E_NOT_INITIALIZED", "Clerk not initialized", nil) - return - } - - let mode = options["mode"] as? String ?? "signInOrUp" - let dismissable = options["dismissable"] as? Bool ?? true - - DispatchQueue.main.async { - guard let vc = factory.createAuthViewController(mode: mode, dismissable: dismissable, completion: { result in - switch result { - case .success(let data): - resolve(data) - case .failure(let error): - reject("E_AUTH_FAILED", error.localizedDescription, error) - } - }) else { - reject("E_CREATE_FAILED", "Could not create auth view controller", nil) - return - } - - if let rootVC = Self.topViewController() { - rootVC.present(vc, animated: true) - } else { - reject("E_NO_ROOT_VC", "No root view controller available to present auth", nil) - } - } - } - - // MARK: - presentUserProfile - - @objc func presentUserProfile(_ options: NSDictionary, - resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { - guard let factory = clerkViewFactory else { - reject("E_NOT_INITIALIZED", "Clerk not initialized", nil) - return - } - - let dismissable = options["dismissable"] as? Bool ?? true - - DispatchQueue.main.async { - guard let vc = factory.createUserProfileViewController(dismissable: dismissable, completion: { result in - switch result { - case .success(let data): - resolve(data) - case .failure(let error): - reject("E_PROFILE_FAILED", error.localizedDescription, error) - } - }) else { - reject("E_CREATE_FAILED", "Could not create profile view controller", nil) - return - } - - if let rootVC = Self.topViewController() { - rootVC.present(vc, animated: true) - } else { - reject("E_NO_ROOT_VC", "No root view controller available to present profile", nil) - } - } - } - // MARK: - getSession @objc func getSession(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - guard let factory = clerkViewFactory else { + guard let bridge = clerkNativeBridge else { resolve(nil) return } Task { - let session = await factory.getSession() + let session = await bridge.getSession() resolve(session) } } @@ -188,276 +105,36 @@ class ClerkExpoModule: RCTEventEmitter { @objc func getClientToken(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - guard let factory = clerkViewFactory else { + guard let bridge = clerkNativeBridge else { resolve(nil) return } - resolve(factory.getClientToken()) + resolve(bridge.getClientToken()) } - // MARK: - signOut + // MARK: - refreshClient - @objc func signOut(_ resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { - guard let factory = clerkViewFactory else { - reject("E_NOT_INITIALIZED", "Clerk not initialized", nil) + @objc func refreshClient(_ resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + guard let bridge = clerkNativeBridge else { + resolve(nil) return } Task { do { - try await factory.signOut() + try await bridge.refreshClient() resolve(nil) } catch { - reject("E_SIGN_OUT_FAILED", error.localizedDescription, error) - } - } - } -} - -// MARK: - Inline View: ClerkAuthNativeView - -public class ClerkAuthNativeView: UIView { - private var currentMode: String = "signInOrUp" - private var currentDismissable: Bool = true - private var hasInitialized: Bool = false - private var authEventSent: Bool = false - private var presentedAuthVC: UIViewController? - private var isInvalidated: Bool = false - - @objc var onAuthEvent: RCTBubblingEventBlock? - - @objc var mode: NSString? { - didSet { - let newMode = (mode as String?) ?? "signInOrUp" - guard newMode != currentMode else { return } - currentMode = newMode - if hasInitialized { - dismissAuthModal() - presentAuthModal() - } - } - } - - @objc var isDismissable: NSNumber? { - didSet { - let newDismissable = isDismissable?.boolValue ?? true - guard newDismissable != currentDismissable else { return } - currentDismissable = newDismissable - if hasInitialized { - dismissAuthModal() - presentAuthModal() - } - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override public func didMoveToWindow() { - super.didMoveToWindow() - if window != nil && !hasInitialized { - hasInitialized = true - presentAuthModal() - } - } - - override public func removeFromSuperview() { - isInvalidated = true - dismissAuthModal() - super.removeFromSuperview() - } - - // MARK: - Modal Presentation - // - // The AuthView is presented as a real modal rather than embedded inline. - // Embedding a UIHostingController as a child of a React Native view disrupts - // ASWebAuthenticationSession callbacks during OAuth flows (e.g., SSO from the - // forgot-password screen). Modal presentation provides an isolated SwiftUI - // lifecycle that handles all OAuth flows correctly. - - private func presentAuthModal() { - guard let factory = clerkViewFactory else { return } - - guard let authVC = factory.createAuthViewController( - mode: currentMode, - dismissable: currentDismissable, - completion: { [weak self] result in - guard let self = self, !self.authEventSent else { return } - switch result { - case .success(let data): - if let _ = data["cancelled"] { - // User dismissed — don't send auth event - return - } - self.authEventSent = true - self.sendAuthEvent(type: "signInCompleted", data: data) - case .failure: - break - } - } - ) else { return } - - authVC.modalPresentationStyle = .fullScreen - // Try to present immediately. Only wait if a previous modal is dismissing. - presentWhenReady(authVC, attempts: 0) - } - - private func dismissAuthModal() { - presentedAuthVC?.dismiss(animated: false) - presentedAuthVC = nil - } - - /// Presents the auth view controller as soon as it's safe to do so. - /// On initial mount this presents synchronously (no delay, no white flash). - /// If a previous modal is still dismissing, waits for its transition coordinator - /// to finish — no fixed delays. - private func presentWhenReady(_ authVC: UIViewController, attempts: Int) { - guard !isInvalidated, presentedAuthVC == nil, attempts < 30 else { return } - guard let rootVC = Self.topViewController() else { - DispatchQueue.main.async { [weak self] in - self?.presentWhenReady(authVC, attempts: attempts + 1) - } - return - } - - // If a previous modal is animating dismissal, wait for it via the - // transition coordinator instead of a fixed delay. - if let coordinator = rootVC.transitionCoordinator { - coordinator.animate(alongsideTransition: nil) { [weak self] _ in - self?.presentWhenReady(authVC, attempts: attempts + 1) + reject("E_REFRESH_CLIENT_FAILED", error.localizedDescription, error) } - return } - - // If there's still a presented VC (no coordinator yet), wait one frame. - if rootVC.presentedViewController != nil { - DispatchQueue.main.async { [weak self] in - self?.presentWhenReady(authVC, attempts: attempts + 1) - } - return - } - - rootVC.present(authVC, animated: false) - presentedAuthVC = authVC } - private static func topViewController() -> UIViewController? { - guard let scene = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .first(where: { $0.activationState == .foregroundActive }), - let rootVC = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController - else { return nil } - - var top = rootVC - while let presented = top.presentedViewController { - top = presented - } - return top - } - - private func sendAuthEvent(type: String, data: [String: Any]) { - let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data() - let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" - onAuthEvent?(["type": type, "data": jsonString]) - - // Also emit module-level event so ClerkProvider's useNativeAuthEvents picks it up - if type == "signInCompleted" || type == "signUpCompleted" { - let sessionId = data["sessionId"] as? String - ClerkExpoModule.emitAuthStateChange(type: "signedIn", sessionId: sessionId) - } - } } -// MARK: - Inline View: ClerkUserProfileNativeView - -public class ClerkUserProfileNativeView: UIView { - private var hostingController: UIViewController? - private var currentDismissable: Bool = true - private var hasInitialized: Bool = false - - @objc var onProfileEvent: RCTBubblingEventBlock? - - @objc var isDismissable: NSNumber? { - didSet { - currentDismissable = isDismissable?.boolValue ?? true - if hasInitialized { updateView() } - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override public func didMoveToWindow() { - super.didMoveToWindow() - if window != nil && !hasInitialized { - hasInitialized = true - updateView() - } - } - - private func updateView() { - // Remove old hosting controller - hostingController?.view.removeFromSuperview() - hostingController?.removeFromParent() - hostingController = nil - - guard let factory = clerkViewFactory else { return } - - guard let returnedController = factory.createUserProfileView( - dismissable: currentDismissable, - onEvent: { [weak self] eventName, data in - let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data() - let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" - self?.onProfileEvent?(["type": eventName, "data": jsonString]) - - // Also emit module-level event for sign-out detection - if eventName == "signedOut" { - let sessionId = data["sessionId"] as? String - ClerkExpoModule.emitAuthStateChange(type: "signedOut", sessionId: sessionId) - } - } - ) else { return } - - if let parentVC = findViewController() { - parentVC.addChild(returnedController) - returnedController.view.frame = bounds - returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - addSubview(returnedController.view) - returnedController.didMove(toParent: parentVC) - hostingController = returnedController - } else { - returnedController.view.frame = bounds - returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - addSubview(returnedController.view) - hostingController = returnedController - } - } - - private func findViewController() -> UIViewController? { - var responder: UIResponder? = self - while let nextResponder = responder?.next { - if let vc = nextResponder as? UIViewController { - return vc - } - responder = nextResponder - } - return nil - } - - override public func layoutSubviews() { - super.layoutSubviews() - hostingController?.view.frame = bounds - } +/// Requests that ClerkProvider reload the JS client from native client state. +public func emitClerkNativeRefreshClient() { + ClerkExpoModule.emitRefreshClient() } diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkNativeBridge.swift similarity index 61% rename from packages/expo/ios/ClerkViewFactory.swift rename to packages/expo/ios/ClerkNativeBridge.swift index 7e1925f41be..8df29c75fdb 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -1,27 +1,32 @@ -// ClerkViewFactory - Provides Clerk view controllers to the ClerkExpo module +// ClerkNativeBridge - Provides app-target Clerk SDK operations and SwiftUI view controllers to ClerkExpo. // This file is injected into the app target by the config plugin. // It uses `import ClerkKit` (SPM) which is only accessible from the app target. import UIKit import SwiftUI +import Observation import Security import ClerkKit import ClerkKitUI -import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol +import ClerkExpo // Import the pod to access ClerkNativeBridgeProtocol -// MARK: - View Factory Implementation +// MARK: - Native Bridge Implementation -public final class ClerkViewFactory: ClerkViewFactoryProtocol { - public static let shared = ClerkViewFactory() +public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { + public static let shared = ClerkNativeBridge() private static let clerkLoadMaxAttempts = 30 private static let clerkLoadIntervalNs: UInt64 = 100_000_000 private static var clerkConfigured = false + private static var configuredPublishableKey: String? /// Parsed light and dark themes from Info.plist "ClerkTheme" dictionary. var lightTheme: ClerkTheme? var darkTheme: ClerkTheme? + private var clientObservationGeneration = 0 + private var lastObservedClient: Client? + private enum KeychainKey { static let jsClientJWT = "__clerk_client_jwt" static let nativeDeviceToken = "clerkDeviceToken" @@ -45,30 +50,85 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { return ExpoKeychain(service: service) } - // Register this factory with the ClerkExpo module + // Register this app-target bridge with the ClerkExpo module. @MainActor public static func register() { shared.loadThemes() - clerkViewFactory = shared + clerkNativeBridge = shared } @MainActor public func configure(publishableKey: String, bearerToken: String? = nil) async throws { + if Self.shouldReconfigure(for: publishableKey) { + try await Clerk.reconfigure(publishableKey: publishableKey, options: Self.makeClerkOptions()) + Self.clerkConfigured = true + Self.configuredPublishableKey = publishableKey + startClientObserver(reset: true) + + Self.syncTokenState(bearerToken: bearerToken) + if !(bearerToken?.isEmpty ?? true) { + _ = try? await Clerk.shared.refreshClient() + } + + await Self.waitForLoadedSession() + return + } + Self.syncTokenState(bearerToken: bearerToken) // If already configured with a new bearer token, refresh the client // to pick up the session associated with the device token we just wrote. - // Clerk.configure() is a no-op on subsequent calls, so we use refreshClient(). + // Clerk.configure() is idempotent for the same publishable key, so use refreshClient(). if Self.shouldRefreshConfiguredClient(for: bearerToken) { + startClientObserver() _ = try? await Clerk.shared.refreshClient() + await Self.waitForLoadedSession() + return + } + + if Self.clerkConfigured { + startClientObserver() return } Self.clerkConfigured = true + Self.configuredPublishableKey = publishableKey Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions()) + startClientObserver() await Self.waitForLoadedSession() } + @MainActor + private func startClientObserver(reset: Bool = false) { + guard reset || clientObservationGeneration == 0 else { return } + + clientObservationGeneration += 1 + let generation = clientObservationGeneration + lastObservedClient = Clerk.shared.client + observeClient(generation: generation) + } + + @MainActor + private func observeClient(generation: Int) { + withObservationTracking { + _ = Clerk.shared.client + } onChange: { [weak self] in + Task { @MainActor [weak self] in + await Task.yield() + + guard let self, generation == self.clientObservationGeneration else { return } + + let newClient = Clerk.shared.client + if newClient != self.lastObservedClient { + self.lastObservedClient = newClient + emitClerkNativeRefreshClient() + } + + self.observeClient(generation: generation) + } + } + } + private static func syncTokenState(bearerToken: String?) { // Sync JS SDK's client token to native keychain so both SDKs share the same client. // This handles the case where the user signed in via JS SDK but the native SDK @@ -95,6 +155,11 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { clerkConfigured && !(bearerToken?.isEmpty ?? true) } + private static func shouldReconfigure(for publishableKey: String) -> Bool { + guard clerkConfigured, let configuredPublishableKey else { return false } + return configuredPublishableKey != publishableKey + } + private static func makeClerkOptions() -> Clerk.Options { guard let service = keychainService else { return .init() @@ -107,7 +172,7 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { // Wait for Clerk to finish loading (cached data + API refresh). // The static configure() fires off async refreshes; poll until loaded. for _ in 0..) -> Void - ) -> UIViewController? { - let wrapper = ClerkAuthWrapperViewController( - mode: Self.authMode(from: mode), - dismissable: dismissable, - lightTheme: lightTheme, - darkTheme: darkTheme, - completion: completion - ) - return wrapper - } - - public func createUserProfileViewController( - dismissable: Bool, - completion: @escaping (Result<[String: Any], Error>) -> Void - ) -> UIViewController? { - let wrapper = ClerkProfileWrapperViewController( - dismissable: dismissable, - lightTheme: lightTheme, - darkTheme: darkTheme, - completion: completion - ) - return wrapper - } - // MARK: - Inline View Creation - public func createAuthView( + public func makeAuthViewController( mode: String, - dismissable: Bool, - onEvent: @escaping (String, [String: Any]) -> Void + dismissible: Bool, + onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { makeHostingController( rootView: ClerkInlineAuthWrapperView( mode: Self.authMode(from: mode), - dismissable: dismissable, + dismissible: dismissible, lightTheme: lightTheme, - darkTheme: darkTheme, - onEvent: onEvent - ) + darkTheme: darkTheme + ), + onDismiss: dismissible ? { onEvent(.dismissed, [:]) } : nil ) } - public func createUserProfileView( - dismissable: Bool, - onEvent: @escaping (String, [String: Any]) -> Void + public func makeUserProfileViewController( + dismissible: Bool, + onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { makeHostingController( rootView: ClerkInlineProfileWrapperView( - dismissable: dismissable, + dismissible: dismissible, + lightTheme: lightTheme, + darkTheme: darkTheme + ), + onDismiss: dismissible ? { onEvent(.dismissed, [:]) } : nil + ) + } + + public func makeUserButtonViewController() -> UIViewController? { + makeHostingController( + rootView: ClerkInlineUserButtonWrapperView( lightTheme: lightTheme, - darkTheme: darkTheme, - onEvent: onEvent + darkTheme: darkTheme ) ) } @@ -218,14 +264,10 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { } @MainActor - public func signOut() async throws { - if Self.clerkConfigured { - defer { Clerk.clearAllKeychainItems() } - if let sessionId = Clerk.shared.session?.id { - try await Clerk.shared.auth.signOut(sessionId: sessionId) - } - } - Self.clerkConfigured = false + public func refreshClient() async throws { + guard Self.clerkConfigured else { return } + _ = try await Clerk.shared.refreshClient() + await Self.waitForLoadedSession() } private static func authMode(from mode: String) -> AuthView.Mode { @@ -324,8 +366,11 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { return ClerkTheme.Design(borderRadius: CGFloat(radius)) } - private func makeHostingController(rootView: Content) -> UIViewController { - let hostingController = UIHostingController(rootView: rootView) + private func makeHostingController( + rootView: Content, + onDismiss: (() -> Void)? = nil + ) -> UIViewController { + let hostingController = ClerkNativeHostingController(rootView: rootView, onDismiss: onDismiss) hostingController.view.backgroundColor = .clear return hostingController } @@ -420,167 +465,27 @@ private struct ExpoKeychain { } } -// MARK: - Auth View Controller Wrapper - -class ClerkAuthWrapperViewController: UIHostingController { - private let completion: (Result<[String: Any], Error>) -> Void - private var authEventTask: Task? - private var completionCalled = false - - init(mode: AuthView.Mode, dismissable: Bool, lightTheme: ClerkTheme?, darkTheme: ClerkTheme?, completion: @escaping (Result<[String: Any], Error>) -> Void) { - self.completion = completion - let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable, lightTheme: lightTheme, darkTheme: darkTheme) - super.init(rootView: view) - self.modalPresentationStyle = .fullScreen - subscribeToAuthEvents() - } - - @MainActor required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - authEventTask?.cancel() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - if isBeingDismissed { - // Check if auth completed (session exists) vs user cancelled - if let session = Clerk.shared.session, session.id != initialSessionId { - completeOnce(.success(["sessionId": session.id, "type": "signIn"])) - } else { - completeOnce(.success(["cancelled": true])) - } - } - } - - private func completeOnce(_ result: Result<[String: Any], Error>) { - guard !completionCalled else { return } - completionCalled = true - completion(result) - } - - private var initialSessionId: String? = Clerk.shared.session?.id - - private func subscribeToAuthEvents() { - authEventTask = Task { @MainActor [weak self] in - for await event in Clerk.shared.auth.events { - guard let self = self, !self.completionCalled else { return } - switch event { - case .signInCompleted(let signIn): - let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id - if let sessionId, sessionId != self.initialSessionId { - self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"])) - self.dismiss(animated: true) - } - case .signUpCompleted(let signUp): - let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id - if let sessionId, sessionId != self.initialSessionId { - self.completeOnce(.success(["sessionId": sessionId, "type": "signUp"])) - self.dismiss(animated: true) - } - case .sessionChanged(_, let newSession): - if let sessionId = newSession?.id, sessionId != self.initialSessionId { - self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"])) - self.dismiss(animated: true) - } - default: - break - } - } - } - } -} +// MARK: - Inline User Button Wrapper (for embedded rendering) -struct ClerkAuthWrapperView: View { - let mode: AuthView.Mode - let dismissable: Bool +struct ClerkInlineUserButtonWrapperView: View { let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? @Environment(\.colorScheme) private var colorScheme var body: some View { - let view = AuthView(mode: mode, isDismissable: dismissable) + let view = UserButton() .environment(Clerk.shared) let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme - if let theme { - view.environment(\.clerkTheme, theme) - } else { - view - } - } -} - -// MARK: - Profile View Controller Wrapper - -class ClerkProfileWrapperViewController: UIHostingController { - private let completion: (Result<[String: Any], Error>) -> Void - private var authEventTask: Task? - private var completionCalled = false - - init(dismissable: Bool, lightTheme: ClerkTheme?, darkTheme: ClerkTheme?, completion: @escaping (Result<[String: Any], Error>) -> Void) { - self.completion = completion - let view = ClerkProfileWrapperView(dismissable: dismissable, lightTheme: lightTheme, darkTheme: darkTheme) - super.init(rootView: view) - self.modalPresentationStyle = .fullScreen - subscribeToAuthEvents() - } - - @MainActor required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - authEventTask?.cancel() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - if isBeingDismissed { - completeOnce(.success(["dismissed": true])) - } - } - - private func completeOnce(_ result: Result<[String: Any], Error>) { - guard !completionCalled else { return } - completionCalled = true - completion(result) - } - - private func subscribeToAuthEvents() { - authEventTask = Task { @MainActor [weak self] in - for await event in Clerk.shared.auth.events { - guard let self = self, !self.completionCalled else { return } - switch event { - case .signedOut(let session): - self.completeOnce(.success(["sessionId": session.id])) - self.dismiss(animated: true) - default: - break - } + let themedView = Group { + if let theme { + view.environment(\.clerkTheme, theme) + } else { + view } } - } -} - -struct ClerkProfileWrapperView: View { - let dismissable: Bool - let lightTheme: ClerkTheme? - let darkTheme: ClerkTheme? - - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - let view = UserProfileView(isDismissable: dismissable) - .environment(Clerk.shared) - let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme - if let theme { - view.environment(\.clerkTheme, theme) - } else { - view - } + themedView + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } } @@ -588,25 +493,14 @@ struct ClerkProfileWrapperView: View { struct ClerkInlineAuthWrapperView: View { let mode: AuthView.Mode - let dismissable: Bool + let dismissible: Bool let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? - let onEvent: (String, [String: Any]) -> Void - - // Track initial session to detect new sign-ins (same approach as Android) - @State private var initialSessionId: String? = Clerk.shared.session?.id - @State private var eventSent = false @Environment(\.colorScheme) private var colorScheme - private func sendAuthCompleted(sessionId: String, type: String) { - guard !eventSent, sessionId != initialSessionId else { return } - eventSent = true - onEvent(type, ["sessionId": sessionId, "type": type == "signUpCompleted" ? "signUp" : "signIn"]) - } - private var themedAuthView: some View { - let view = AuthView(mode: mode, isDismissable: dismissable) + let view = AuthView(mode: mode, isDismissable: dismissible) .environment(Clerk.shared) let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme return Group { @@ -620,45 +514,45 @@ struct ClerkInlineAuthWrapperView: View { var body: some View { themedAuthView - // Primary detection: observe Clerk.shared.session directly (matches Android's sessionFlow approach). - // This is more reliable than auth.events which may not emit for inline AuthView sign-ins. - .onChange(of: Clerk.shared.session?.id) { _, newSessionId in - guard let sessionId = newSessionId else { return } - sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") - } - // Fallback: also listen to auth.events for signUp events and edge cases - .task { - for await event in Clerk.shared.auth.events { - guard !eventSent else { continue } - switch event { - case .signInCompleted(let signIn): - let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id - if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } - case .signUpCompleted(let signUp): - let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id - if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signUpCompleted") } - case .sessionChanged(_, let newSession): - if let sessionId = newSession?.id { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } - default: - break - } - } - } + } +} + +private final class ClerkNativeHostingController: UIHostingController { + private let onDismiss: (() -> Void)? + private var didSendDismiss = false + + init(rootView: Content, onDismiss: (() -> Void)? = nil) { + self.onDismiss = onDismiss + super.init(rootView: rootView) + } + + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { + sendDismissIfNeeded() + super.dismiss(animated: flag, completion: completion) + } + + private func sendDismissIfNeeded() { + guard !didSendDismiss else { return } + didSendDismiss = true + onDismiss?() } } // MARK: - Inline Profile View Wrapper (for embedded rendering) struct ClerkInlineProfileWrapperView: View { - let dismissable: Bool + let dismissible: Bool let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? - let onEvent: (String, [String: Any]) -> Void @Environment(\.colorScheme) private var colorScheme var body: some View { - let view = UserProfileView(isDismissable: dismissable) + let view = UserProfileView(isDismissable: dismissible) .environment(Clerk.shared) let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme let themedView = Group { @@ -669,15 +563,5 @@ struct ClerkInlineProfileWrapperView: View { } } themedView - .task { - for await event in Clerk.shared.auth.events { - switch event { - case .signedOut(let session): - onEvent("signedOut", ["sessionId": session.id]) - default: - break - } - } - } } } diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo/ios/ClerkNativeViewHost.swift new file mode 100644 index 00000000000..58385486eb3 --- /dev/null +++ b/packages/expo/ios/ClerkNativeViewHost.swift @@ -0,0 +1,113 @@ +import UIKit + +public class ClerkNativeViewHost: UIView { + private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) + private var hasInitialized: Bool = false + + override public init(frame: CGRect) { + super.init(frame: frame) + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func didMoveToWindow() { + super.didMoveToWindow() + + guard window != nil else { + if hasInitialized { + hostedViewDidDetachFromWindow() + } + hostingCoordinator.detach() + hasInitialized = false + return + } + + guard !hasInitialized else { return } + hasInitialized = true + hostedViewDidAttachToWindow() + updateHostedView() + } + + override public func layoutSubviews() { + super.layoutSubviews() + hostingCoordinator.layout() + } + + func setNeedsHostedViewUpdate() { + guard hasInitialized else { return } + updateHostedView() + } + + func makeHostedController() -> UIViewController? { + nil + } + + // Subclasses can observe attach/detach without making this host know about their RN event props. + func hostedViewDidAttachToWindow() {} + + func hostedViewDidDetachFromWindow() {} + + private func updateHostedView() { + guard let controller = makeHostedController() else { return } + hostingCoordinator.attach(controller) + } +} + +private final class ClerkNativeHostingCoordinator { + private weak var containerView: UIView? + private var hostingController: UIViewController? + + init(containerView: UIView) { + self.containerView = containerView + } + + func attach(_ controller: UIViewController) { + detach() + + guard let containerView else { return } + + controller.view.frame = containerView.bounds + controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + if let parentVC = findViewController(from: containerView) { + parentVC.addChild(controller) + containerView.addSubview(controller.view) + controller.didMove(toParent: parentVC) + } else { + containerView.addSubview(controller.view) + } + + hostingController = controller + } + + func detach() { + guard let controller = hostingController else { return } + + if controller.parent != nil { + controller.willMove(toParent: nil) + } + controller.view.removeFromSuperview() + if controller.parent != nil { + controller.removeFromParent() + } + hostingController = nil + } + + func layout() { + guard let containerView else { return } + hostingController?.view.frame = containerView.bounds + } + + private func findViewController(from view: UIView) -> UIViewController? { + var responder: UIResponder? = view + while let nextResponder = responder?.next { + if let vc = nextResponder as? UIViewController { + return vc + } + responder = nextResponder + } + return nil + } +} diff --git a/packages/expo/ios/ClerkUserButtonNativeView.swift b/packages/expo/ios/ClerkUserButtonNativeView.swift new file mode 100644 index 00000000000..9fe39622752 --- /dev/null +++ b/packages/expo/ios/ClerkUserButtonNativeView.swift @@ -0,0 +1,22 @@ +import React +import UIKit + +public class ClerkUserButtonNativeView: ClerkNativeViewHost { + override func makeHostedController() -> UIViewController? { + guard let bridge = clerkNativeBridge else { return nil } + + return bridge.makeUserButtonViewController() + } +} + +@objc(ClerkUserButtonViewManager) +class ClerkUserButtonViewManager: RCTViewManager { + + override static func requiresMainQueueSetup() -> Bool { + return true + } + + override func view() -> UIView! { + return ClerkUserButtonNativeView() + } +} diff --git a/packages/expo/ios/ClerkUserButtonViewManager.m b/packages/expo/ios/ClerkUserButtonViewManager.m new file mode 100644 index 00000000000..5d353edc6a4 --- /dev/null +++ b/packages/expo/ios/ClerkUserButtonViewManager.m @@ -0,0 +1,5 @@ +#import + +@interface RCT_EXTERN_MODULE(ClerkUserButtonViewManager, RCTViewManager) + +@end diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo/ios/ClerkUserProfileNativeView.swift new file mode 100644 index 00000000000..412a3940ad9 --- /dev/null +++ b/packages/expo/ios/ClerkUserProfileNativeView.swift @@ -0,0 +1,62 @@ +import React +import UIKit + +public class ClerkUserProfileNativeView: ClerkNativeViewHost { + private var currentDismissible: Bool = true + private var didSendDismiss = false + + @objc var onProfileEvent: RCTBubblingEventBlock? + + @objc var isDismissible: NSNumber? { + didSet { + let newDismissible = isDismissible?.boolValue ?? true + guard newDismissible != currentDismissible else { return } + currentDismissible = newDismissible + setNeedsHostedViewUpdate() + } + } + + private func sendProfileEvent(type: ClerkNativeViewEvent) { + onProfileEvent?(["type": type.rawValue]) + } + + private func sendDismissIfNeeded() { + // SwiftUI dismissals detach the hosted view without calling UIKit dismiss(). + guard currentDismissible, !didSendDismiss else { return } + didSendDismiss = true + sendProfileEvent(type: .dismissed) + } + + override func hostedViewDidAttachToWindow() { + didSendDismiss = false + } + + override func hostedViewDidDetachFromWindow() { + sendDismissIfNeeded() + } + + override func makeHostedController() -> UIViewController? { + guard let bridge = clerkNativeBridge else { return nil } + + return bridge.makeUserProfileViewController( + dismissible: currentDismissible, + onEvent: { [weak self] event, _ in + if event == .dismissed { + self?.sendDismissIfNeeded() + } + } + ) + } +} + +@objc(ClerkUserProfileViewManager) +class ClerkUserProfileViewManager: RCTViewManager { + + override static func requiresMainQueueSetup() -> Bool { + return true + } + + override func view() -> UIView! { + return ClerkUserProfileNativeView() + } +} diff --git a/packages/expo/ios/ClerkUserProfileViewManager.m b/packages/expo/ios/ClerkUserProfileViewManager.m index 35eaf720ed9..ee06c66a125 100644 --- a/packages/expo/ios/ClerkUserProfileViewManager.m +++ b/packages/expo/ios/ClerkUserProfileViewManager.m @@ -2,7 +2,7 @@ @interface RCT_EXTERN_MODULE(ClerkUserProfileViewManager, RCTViewManager) -RCT_EXPORT_VIEW_PROPERTY(isDismissable, NSNumber) +RCT_EXPORT_VIEW_PROPERTY(isDismissible, NSNumber) RCT_EXPORT_VIEW_PROPERTY(onProfileEvent, RCTBubblingEventBlock) @end diff --git a/packages/expo/ios/ClerkUserProfileViewManager.swift b/packages/expo/ios/ClerkUserProfileViewManager.swift deleted file mode 100644 index b8e9c269f6a..00000000000 --- a/packages/expo/ios/ClerkUserProfileViewManager.swift +++ /dev/null @@ -1,13 +0,0 @@ -import React - -@objc(ClerkUserProfileViewManager) -class ClerkUserProfileViewManager: RCTViewManager { - - override static func requiresMainQueueSetup() -> Bool { - return true - } - - override func view() -> UIView! { - return ClerkUserProfileNativeView() - } -} diff --git a/packages/expo/package.json b/packages/expo/package.json index 647eacfdd6b..c045ebdc81a 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -183,9 +183,6 @@ "codegenConfig": { "name": "ClerkExpoSpec", "type": "all", - "jsSrcsDir": "src/specs", - "android": { - "javaPackageName": "expo.modules.clerk" - } + "jsSrcsDir": "src/specs" } } diff --git a/packages/expo/react-native.config.js b/packages/expo/react-native.config.js index 84cec6c149d..b711c2c04f1 100644 --- a/packages/expo/react-native.config.js +++ b/packages/expo/react-native.config.js @@ -2,10 +2,7 @@ module.exports = { dependency: { platforms: { ios: {}, - android: { - packageImportPath: 'import expo.modules.clerk.ClerkPackage;', - packageInstance: 'new ClerkPackage()', - }, + android: null, }, }, }; diff --git a/packages/expo/src/cache/types.ts b/packages/expo/src/cache/types.ts index 30aa2ae124c..f2da7c72187 100644 --- a/packages/expo/src/cache/types.ts +++ b/packages/expo/src/cache/types.ts @@ -3,7 +3,7 @@ import type { IStorage } from '../provider/singleton/types'; export interface TokenCache { getToken: (key: string) => Promise; saveToken: (key: string, token: string) => Promise; - clearToken?: (key: string) => void; + clearToken?: (key: string) => void | Promise; } export interface ResourceCache { diff --git a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts index c297713a801..ca790a86266 100644 --- a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts +++ b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts @@ -52,7 +52,7 @@ vi.mock('../../specs/NativeClerkModule', () => { configure: vi.fn(), getSession: vi.fn(), getClientToken: vi.fn(), - signOut: vi.fn(), + refreshClient: vi.fn(), }, }; }); diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts index 6c7f22b4d43..8dab9679b19 100644 --- a/packages/expo/src/hooks/index.ts +++ b/packages/expo/src/hooks/index.ts @@ -18,5 +18,3 @@ export * from './useSSO'; export * from './useOAuth'; export * from './useAuth'; export * from './useNativeSession'; -export * from './useNativeAuthEvents'; -export * from './useUserProfileModal'; diff --git a/packages/expo/src/hooks/useNativeAuthEvents.ts b/packages/expo/src/hooks/useNativeAuthEvents.ts deleted file mode 100644 index 7a18e4df16f..00000000000 --- a/packages/expo/src/hooks/useNativeAuthEvents.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { useEffect, useState } from 'react'; -import { NativeEventEmitter } from 'react-native'; - -import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; - -/** - * Auth state change event from native SDK - */ -export interface NativeAuthStateEvent { - type: 'signedIn' | 'signedOut'; - sessionId: string | null; -} - -export interface UseNativeAuthEventsReturn { - /** - * The latest auth state event from the native SDK. - * Will be null until an event is received. - */ - nativeAuthState: NativeAuthStateEvent | null; - - /** - * Whether native event listening is supported (plugin installed) - */ - isSupported: boolean; -} - -/** - * Hook to listen for auth state change events from the native Clerk SDK. - * - * This provides reactive updates when the user signs in or out via native UI. - * Events are emitted by the native module when: - * - User completes sign-in (signInCompleted event from clerk-ios/clerk-android) - * - User completes sign-up (signUpCompleted event from clerk-ios/clerk-android) - * - User signs out (signedOut event from clerk-ios/clerk-android) - * - * @example - * ```tsx - * import { useNativeAuthEvents } from '@clerk/expo'; - * - * function MyComponent() { - * const { nativeAuthState, isSupported } = useNativeAuthEvents(); - * - * useEffect(() => { - * if (nativeAuthState?.type === 'signedIn') { - * console.log('User signed in via native UI'); - * } else if (nativeAuthState?.type === 'signedOut') { - * console.log('User signed out via native UI'); - * } - * }, [nativeAuthState]); - * - * return ; - * } - * ``` - */ -export function useNativeAuthEvents(): UseNativeAuthEventsReturn { - const [nativeAuthState, setNativeAuthState] = useState(null); - - useEffect(() => { - if (!isNativeSupported || !ClerkExpo) { - return; - } - - let subscription: { remove: () => void } | null = null; - - try { - const eventEmitter = new NativeEventEmitter(ClerkExpo as any); - - subscription = eventEmitter.addListener('onAuthStateChange', (event: NativeAuthStateEvent) => { - if (__DEV__) { - console.log('[useNativeAuthEvents] onAuthStateChange:', JSON.stringify(event)); - } - setNativeAuthState(event); - }); - } catch (error) { - if (__DEV__) { - console.error('[useNativeAuthEvents] Failed to set up event listener:', error); - } - } - - return () => { - subscription?.remove(); - }; - }, []); - - return { - nativeAuthState, - isSupported: isNativeSupported && !!ClerkExpo, - }; -} diff --git a/packages/expo/src/hooks/useNativeClientEvents.ts b/packages/expo/src/hooks/useNativeClientEvents.ts new file mode 100644 index 00000000000..03b4fcfd3e8 --- /dev/null +++ b/packages/expo/src/hooks/useNativeClientEvents.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import { NativeEventEmitter, Platform } from 'react-native'; + +import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; + +/** + * Local marker for a native client event. + */ +interface NativeClientEvent { + issuedAt: number; +} + +interface UseNativeClientEventsReturn { + nativeClientEvent: NativeClientEvent | null; +} + +type RefreshClientEventSubscription = { + remove: () => void; +}; + +type RefreshClientEventEmitter = { + addListener: (eventName: 'refreshClient', listener: () => void) => RefreshClientEventSubscription; +}; + +/** + * Listens for native client events that should sync JS client state. + */ +export function useNativeClientEvents(): UseNativeClientEventsReturn { + const [nativeClientEvent, setNativeClientEvent] = useState(null); + + useEffect(() => { + if (!isNativeSupported || !ClerkExpo) { + return; + } + + let subscription: { remove: () => void } | null = null; + + try { + const eventEmitter: RefreshClientEventEmitter = + Platform.OS === 'android' + ? (ClerkExpo as RefreshClientEventEmitter) + : (new NativeEventEmitter(ClerkExpo) as RefreshClientEventEmitter); + + subscription = eventEmitter.addListener('refreshClient', () => { + setNativeClientEvent({ issuedAt: Date.now() }); + }); + } catch (error) { + if (__DEV__) { + console.error('[useNativeClientEvents] Failed to set up event listener:', error); + } + } + + return () => { + subscription?.remove(); + }; + }, []); + + return { + nativeClientEvent, + }; +} diff --git a/packages/expo/src/hooks/useUserProfileModal.ts b/packages/expo/src/hooks/useUserProfileModal.ts deleted file mode 100644 index da7c6f4d081..00000000000 --- a/packages/expo/src/hooks/useUserProfileModal.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { useClerk, useUser } from '@clerk/react'; -import { useCallback, useRef } from 'react'; - -import { CLERK_CLIENT_JWT_KEY } from '../constants'; -import { tokenCache } from '../token-cache'; -import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; - -// Raw result from the native module (may vary by platform) -type NativeSessionResult = { - sessionId?: string; - session?: { id: string }; -}; - -export interface UseUserProfileModalReturn { - /** - * Present the native user profile modal. - * - * The returned promise resolves when the modal is dismissed. - * If the user signed out from within the profile modal, - * the JS SDK session is automatically cleared. - */ - presentUserProfile: () => Promise; - - /** - * Whether the native module supports presenting the profile modal. - */ - isAvailable: boolean; -} - -/** - * Imperative hook for presenting the native user profile modal. - * - * Call `presentUserProfile()` from a button's `onPress` to show the native - * profile management screen (SwiftUI on iOS, Jetpack Compose on Android). - * The promise resolves when the modal is dismissed. - * - * Sign-out is detected automatically — if the user signs out from within - * the profile modal, the JS SDK session is cleared so `useAuth()` updates - * reactively. - * - * @example - * ```tsx - * import { useUserProfileModal } from '@clerk/expo'; - * - * function MyScreen() { - * const { presentUserProfile } = useUserProfileModal(); - * - * return ( - * - * Manage Profile - * - * ); - * } - * ``` - */ -export function useUserProfileModal(): UseUserProfileModalReturn { - const clerk = useClerk(); - const { user } = useUser(); - const presentingRef = useRef(false); - - const presentUserProfile = useCallback(async () => { - if (presentingRef.current) { - return; - } - - if (!isNativeSupported || !ClerkExpo?.presentUserProfile) { - return; - } - - presentingRef.current = true; - try { - let hadNativeSessionBefore = false; - - // If native doesn't have a session but JS does (e.g. user signed in via custom form), - // sync the JS SDK's bearer token to native and wait for it before presenting. - if (user && ClerkExpo?.getSession && ClerkExpo?.configure) { - const preCheck = (await ClerkExpo.getSession()) as NativeSessionResult | null; - hadNativeSessionBefore = !!(preCheck?.sessionId || preCheck?.session?.id); - - if (!hadNativeSessionBefore) { - const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; - if (bearerToken) { - await ClerkExpo.configure(clerk.publishableKey, bearerToken); - - // Re-check if configure produced a session - const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null; - hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id); - } - } - } - - await ClerkExpo.presentUserProfile({ - dismissable: true, - }); - - // Only sign out the JS SDK if native HAD a session before the modal - // and now it's gone (user signed out from within native UI). - const sessionCheck = (await ClerkExpo.getSession?.()) as NativeSessionResult | null; - const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id); - - if (!hasNativeSession && hadNativeSessionBefore) { - try { - await ClerkExpo.signOut?.(); - } catch (e) { - if (__DEV__) { - console.warn('[useUserProfileModal] Native signOut error (may already be signed out):', e); - } - } - - if (clerk?.signOut) { - try { - await clerk.signOut(); - } catch (e) { - if (__DEV__) { - console.warn('[useUserProfileModal] Best-effort JS SDK signOut failed:', e); - } - } - } - } - } catch (error) { - if (__DEV__) { - console.error('[useUserProfileModal] presentUserProfile failed:', error); - } - } finally { - presentingRef.current = false; - } - }, [clerk, user]); - - return { - presentUserProfile, - isAvailable: isNativeSupported && !!ClerkExpo?.presentUserProfile, - }; -} diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx index a101a7cd0ad..e10997f9dff 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo/src/native/AuthView.tsx @@ -1,59 +1,11 @@ -import { ClerkRuntimeError } from '@clerk/shared/error'; -import { useCallback, useRef } from 'react'; +import { type ComponentProps, type ReactElement, useCallback } from 'react'; import { Text, View } from 'react-native'; -import { CLERK_CLIENT_JWT_KEY } from '../constants'; -import { getClerkInstance } from '../provider/singleton'; import NativeClerkAuthView from '../specs/NativeClerkAuthView'; -import { tokenCache } from '../token-cache'; -import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; +import { isNativeSupported } from '../utils/native-module'; import type { AuthViewProps } from './AuthView.types'; -export async function syncNativeSession(sessionId: string): Promise { - // Copy the native client's bearer token to the JS SDK's token cache - if (ClerkExpo?.getClientToken) { - const nativeClientToken = await ClerkExpo.getClientToken(); - if (__DEV__) { - console.log( - '[syncNativeSession] getClientToken:', - nativeClientToken ? `${nativeClientToken.slice(0, 20)}...` : 'null', - ); - } - if (nativeClientToken) { - await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); - } - } - - const clerkInstance = getClerkInstance(); - if (!clerkInstance) { - throw new ClerkRuntimeError( - 'Clerk instance is not available. Ensure is mounted before using .', - { code: 'expo_auth_view_clerk_instance_not_available' }, - ); - } - - // Reload resources using the native client's token - const clerkRecord = clerkInstance as unknown as Record; - if (typeof clerkRecord.__internal_reloadInitialResources === 'function') { - if (__DEV__) { - console.log('[syncNativeSession] reloading initial resources...'); - } - await (clerkRecord.__internal_reloadInitialResources as () => Promise)(); - if (__DEV__) { - console.log('[syncNativeSession] reload complete'); - } - } - - if (typeof clerkInstance.setActive === 'function') { - if (__DEV__) { - console.log('[syncNativeSession] calling setActive with session:', sessionId); - } - await clerkInstance.setActive({ session: sessionId }); - if (__DEV__) { - console.log('[syncNativeSession] setActive complete'); - } - } -} +type AuthNativeEvent = Parameters['onAuthEvent']>>[0]; /** * A pre-built native authentication component that handles sign-in and sign-up flows. @@ -63,7 +15,8 @@ export async function syncNativeSession(sessionId: string): Promise { * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android * * After authentication completes, the session is automatically synced with the JS SDK. - * Use `useAuth()`, `useUser()`, or `useSession()` in a `useEffect` to react to state changes. + * Use `useAuth()`, `useUser()`, or `useSession()` to react to authentication + * state changes. * * @example * ```tsx @@ -83,49 +36,14 @@ export async function syncNativeSession(sessionId: string): Promise { * * @see {@link https://clerk.com/docs/components/authentication/sign-in} Clerk Sign-In Documentation */ -export function AuthView({ mode = 'signInOrUp', isDismissable = false }: AuthViewProps) { - const authCompletedRef = useRef(false); - - const syncSession = useCallback(async (sessionId: string) => { - if (authCompletedRef.current) { - return; - } - - if (__DEV__) { - console.log('[AuthView] syncSession called with sessionId:', sessionId); - } - - try { - await syncNativeSession(sessionId); - authCompletedRef.current = true; - if (__DEV__) { - console.log('[AuthView] syncSession succeeded'); - } - } catch (err) { - if (__DEV__) { - console.error('[AuthView] Failed to sync session:', err); - } - } - }, []); - +export function AuthView({ mode = 'signInOrUp', isDismissible = true, onDismiss }: AuthViewProps): ReactElement { const handleAuthEvent = useCallback( - async (event: { nativeEvent: { type: string; data: string } }) => { - const { type, data: rawData } = event.nativeEvent; - if (__DEV__) { - console.log('[AuthView] onAuthEvent:', type, rawData); - } - const data: Record = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; - - if (type === 'signInCompleted' || type === 'signUpCompleted') { - const sessionId = data?.sessionId; - if (sessionId) { - await syncSession(sessionId); - } else if (__DEV__) { - console.warn('[AuthView] Auth event received but no sessionId in data:', data); - } + (event: AuthNativeEvent) => { + if (event.nativeEvent.type === 'dismissed') { + onDismiss?.(); } }, - [syncSession], + [onDismiss], ); if (!isNativeSupported || !NativeClerkAuthView) { @@ -144,7 +62,7 @@ export function AuthView({ mode = 'signInOrUp', isDismissable = false }: AuthVie ); diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts index 2f316488827..70b0c83fca4 100644 --- a/packages/expo/src/native/AuthView.types.ts +++ b/packages/expo/src/native/AuthView.types.ts @@ -11,8 +11,8 @@ export type AuthViewMode = 'signIn' | 'signUp' | 'signInOrUp'; * Props for the AuthView component. * * AuthView renders a native authentication UI inline (fills parent container). - * Use `useAuth()`, `useUser()`, or `useSession()` in a `useEffect` to react - * to authentication state changes. + * Use `useAuth()`, `useUser()`, or `useSession()` to react to authentication + * state changes. */ export interface AuthViewProps { /** @@ -34,7 +34,15 @@ export interface AuthViewProps { * When `false`, the user must complete authentication to close the view. * Use this for flows where authentication is required to proceed. * - * @default false + * @default true */ - isDismissable?: boolean; + isDismissible?: boolean; + + /** + * Called when the native authentication view requests dismissal. + * + * This fires when the user dismisses the view, or when the native auth flow + * finishes and the app-owned presentation should close. + */ + onDismiss?: () => void; } diff --git a/packages/expo/src/native/InlineAuthView.tsx b/packages/expo/src/native/InlineAuthView.tsx deleted file mode 100644 index e4c2b682871..00000000000 --- a/packages/expo/src/native/InlineAuthView.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { ClerkRuntimeError } from '@clerk/shared/error'; -import { useCallback, useRef } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; - -import { CLERK_CLIENT_JWT_KEY } from '../constants'; -import { getClerkInstance } from '../provider/singleton'; -import NativeClerkAuthView from '../specs/NativeClerkAuthView'; -import { tokenCache } from '../token-cache'; -import { ClerkExpoModule, isNativeSupported } from '../utils/native-module'; -import type { AuthViewMode } from './AuthView.types'; - -export interface InlineAuthViewProps { - /** - * Authentication mode that determines which flows are available. - * @default 'signInOrUp' - */ - mode?: AuthViewMode; - - /** - * Whether the authentication view can be dismissed by the user. - * @default false - */ - isDismissable?: boolean; -} - -/** - * An inline native authentication component that renders in-place. - * - * `InlineAuthView` renders directly within your React Native view hierarchy, - * allowing you to embed the native authentication UI anywhere in your layout. - * - * After authentication completes, the session is automatically synced with the JS SDK. - * Use `useAuth()`, `useUser()`, or `useSession()` in a `useEffect` to react to state changes. - * - * @example - * ```tsx - * import { InlineAuthView } from '@clerk/expo/native'; - * import { useAuth } from '@clerk/expo'; - * - * export default function SignInScreen() { - * const { isSignedIn } = useAuth(); - * - * useEffect(() => { - * if (isSignedIn) router.replace('/home'); - * }, [isSignedIn]); - * - * return ( - * - * Welcome - * - * - * ); - * } - * ``` - */ -export function InlineAuthView({ mode = 'signInOrUp', isDismissable = false }: InlineAuthViewProps) { - const authCompletedRef = useRef(false); - - const syncSession = useCallback(async (sessionId: string) => { - if (authCompletedRef.current) { - return; - } - - try { - if (ClerkExpoModule?.getClientToken) { - const nativeClientToken = await ClerkExpoModule.getClientToken(); - if (nativeClientToken) { - await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); - } - } - - const clerkInstance = getClerkInstance(); - if (!clerkInstance) { - throw new ClerkRuntimeError( - 'Clerk instance is not available. Ensure is mounted before using .', - { code: 'expo_inline_auth_view_clerk_instance_not_available' }, - ); - } - - const clerkRecord = clerkInstance as unknown as Record; - if (typeof clerkRecord.__internal_reloadInitialResources === 'function') { - await (clerkRecord.__internal_reloadInitialResources as () => Promise)(); - } - - if (typeof clerkInstance.setActive === 'function') { - await clerkInstance.setActive({ session: sessionId }); - } - - authCompletedRef.current = true; - } catch (err) { - if (__DEV__) { - console.error('[InlineAuthView] Failed to sync session:', err); - } - } - }, []); - - const handleAuthEvent = useCallback( - async (event: { nativeEvent: { type: string; data: string } }) => { - const { type, data: rawData } = event.nativeEvent; - if (__DEV__) { - console.log('[InlineAuthView] onAuthEvent:', type, rawData); - } - const data: Record = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; - - if (type === 'signInCompleted' || type === 'signUpCompleted') { - const sessionId = data?.sessionId; - if (sessionId) { - await syncSession(sessionId); - } else if (__DEV__) { - console.warn('[InlineAuthView] Auth event received but no sessionId in data:', data); - } - } - }, - [syncSession], - ); - - if (!isNativeSupported || !NativeClerkAuthView) { - return ( - - - {!isNativeSupported - ? 'Native InlineAuthView is only available on iOS and Android' - : 'Native InlineAuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} - - - ); - } - - return ( - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - fallback: { - justifyContent: 'center', - alignItems: 'center', - }, - text: { - fontSize: 16, - color: '#666', - }, -}); diff --git a/packages/expo/src/native/InlineUserProfileView.tsx b/packages/expo/src/native/InlineUserProfileView.tsx deleted file mode 100644 index ecf38f46214..00000000000 --- a/packages/expo/src/native/InlineUserProfileView.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useClerk } from '@clerk/react'; -import { useCallback, useRef } from 'react'; -import { type StyleProp, StyleSheet, Text, View, type ViewStyle } from 'react-native'; - -import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView'; -import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; - -export interface InlineUserProfileViewProps { - /** - * Whether the profile view can be dismissed by the user. - * @default false - */ - isDismissable?: boolean; - - /** - * Style applied to the container view. - */ - style?: StyleProp; -} - -/** - * An inline native user profile component that renders in-place. - * - * `InlineUserProfileView` renders directly within your React Native view hierarchy. - * - * Sign-out is detected automatically and synced with the JS SDK. Use `useAuth()` in a - * `useEffect` to react to sign-out. - * - * @example - * ```tsx - * import { InlineUserProfileView } from '@clerk/expo/native'; - * import { useAuth } from '@clerk/expo'; - * - * export default function ProfileScreen() { - * const { isSignedIn } = useAuth(); - * - * useEffect(() => { - * if (!isSignedIn) router.replace('/sign-in'); - * }, [isSignedIn]); - * - * return ; - * } - * ``` - */ -export function InlineUserProfileView({ isDismissable = false, style }: InlineUserProfileViewProps) { - const clerk = useClerk(); - const signOutTriggered = useRef(false); - - const handleProfileEvent = useCallback( - async (event: { nativeEvent: { type: string; data: string } }) => { - const { type } = event.nativeEvent; - - if (type === 'signedOut' && !signOutTriggered.current) { - signOutTriggered.current = true; - - // Clear native session - try { - await ClerkExpo?.signOut(); - } catch (e) { - if (__DEV__) { - console.warn('[InlineUserProfileView] Native signOut error (may already be signed out):', e); - } - } - - // Sign out from JS SDK - if (clerk?.signOut) { - try { - await clerk.signOut(); - } catch (err) { - if (__DEV__) { - console.warn('[InlineUserProfileView] JS SDK sign out error:', err); - } - } - } - } - }, - [clerk], - ); - - if (!isNativeSupported || !NativeClerkUserProfileView) { - return ( - - - {!isNativeSupported - ? 'Native InlineUserProfileView is only available on iOS and Android' - : 'Native InlineUserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} - - - ); - } - - return ( - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - text: { - fontSize: 16, - color: '#666', - }, -}); diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx index 4e0795970ff..96bf4e37da6 100644 --- a/packages/expo/src/native/UserButton.tsx +++ b/packages/expo/src/native/UserButton.tsx @@ -1,257 +1,38 @@ -import { useClerk, useUser } from '@clerk/react'; -import { useEffect, useRef, useState } from 'react'; -import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { StyleSheet } from 'react-native'; -import { CLERK_CLIENT_JWT_KEY } from '../constants'; -import { tokenCache } from '../token-cache'; -import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; - -// Raw result from native module (may vary by platform) -interface NativeSessionResult { - sessionId?: string; - session?: { id: string }; - user?: { id: string; firstName?: string; lastName?: string; imageUrl?: string; primaryEmailAddress?: string }; -} - -function getInitials(user: { firstName?: string; lastName?: string } | null): string { - if (user?.firstName) { - const first = user.firstName.charAt(0).toUpperCase(); - const last = user.lastName?.charAt(0).toUpperCase() || ''; - return first + last; - } - return 'U'; -} - -interface NativeUser { - id: string; - firstName?: string; - lastName?: string; - imageUrl?: string; - primaryEmailAddress?: string; -} - -/** - * Props for the UserButton component. - */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface UserButtonProps {} +import NativeClerkUserButtonView from '../specs/NativeClerkUserButtonView'; +import { isNativeSupported } from '../utils/native-module'; /** - * A pre-built native button component that displays the user's avatar and opens their profile. + * A pre-built button component that displays the user's avatar. * - * `UserButton` renders a circular button showing the user's profile image (or initials if - * no image is available). When tapped, it presents the native profile management modal. + * `UserButton` renders the platform-native Clerk user button. Tapping it opens + * the native user profile surface, matching Clerk's iOS and Android SDKs. * - * Sign-out is detected automatically and synced with the JS SDK, causing `useAuth()` to - * update reactively. Use `useAuth()` in a `useEffect` to react to sign-out. - * - * @example Basic usage in a header - * ```tsx - * import { UserButton } from '@clerk/expo/native'; - * - * export default function Header() { - * return ( - * - * My App - * - * - * ); - * } - * ``` - * - * @example Reacting to sign-out + * @example * ```tsx * import { UserButton } from '@clerk/expo/native'; - * import { useAuth } from '@clerk/expo'; - * - * export default function Header() { - * const { isSignedIn } = useAuth(); * - * useEffect(() => { - * if (!isSignedIn) router.replace('/sign-in'); - * }, [isSignedIn]); - * - * return ; + * export default function Home() { + * return ; * } * ``` * - * @see {@link UserProfileView} The profile view that opens when tapped + * @see {@link UserProfileView} The profile view to render in your own presentation surface * @see {@link https://clerk.com/docs/components/user/user-button} Clerk UserButton Documentation */ -export function UserButton(_props: UserButtonProps) { - const [nativeUser, setNativeUser] = useState(null); - const presentingRef = useRef(false); - const clerk = useClerk(); - // Use the reactive user hook from clerk-react to observe sign-out state changes - const { user: clerkUser } = useUser(); - - // Fetch native user data on mount and when clerk user changes - useEffect(() => { - const fetchUser = async () => { - if (!isNativeSupported || !ClerkExpo?.getSession) { - return; - } - - try { - const result = (await ClerkExpo.getSession()) as NativeSessionResult | null; - const hasSession = !!(result?.sessionId || result?.session?.id); - if (hasSession && result?.user) { - setNativeUser(result.user); - } else { - // Clear local state if no native session - setNativeUser(null); - } - } catch (err) { - if (__DEV__) { - console.error('[UserButton] Error fetching user:', err); - } - } - }; - - void fetchUser(); - }, [clerkUser?.id]); // Re-fetch when clerk user changes (including sign-out) - - // Derive the user to display - prefer native data, fall back to clerk-react data - const user: NativeUser | null = - nativeUser ?? - (clerkUser - ? { - id: clerkUser.id, - firstName: clerkUser.firstName ?? undefined, - lastName: clerkUser.lastName ?? undefined, - imageUrl: clerkUser.imageUrl ?? undefined, - primaryEmailAddress: clerkUser.primaryEmailAddress?.emailAddress, - } - : null); - - const handlePress = async () => { - if (presentingRef.current) { - return; - } - - if (!isNativeSupported || !ClerkExpo?.presentUserProfile) { - return; - } - - presentingRef.current = true; - try { - // Track whether native had a session before the modal, so we can distinguish - // "user signed out from within the modal" from "native never had a session". - let hadNativeSessionBefore = false; - - // If native doesn't have a session but JS does (e.g. user signed in via custom form), - // sync the JS SDK's bearer token to native and wait for it before presenting. - if (clerkUser && ClerkExpo?.getSession && ClerkExpo?.configure) { - const preCheck = (await ClerkExpo.getSession()) as NativeSessionResult | null; - hadNativeSessionBefore = !!(preCheck?.sessionId || preCheck?.session?.id); - - if (!hadNativeSessionBefore) { - const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; - if (bearerToken) { - await ClerkExpo.configure(clerk.publishableKey, bearerToken); - - // Re-check if configure produced a session - const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null; - hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id); - } - } - } - - await ClerkExpo.presentUserProfile({ - dismissable: true, - }); - - // Check if native session still exists after modal closes. - // Only sign out the JS SDK if the native SDK HAD a session before the modal - // and now it's gone (meaning the user signed out from within the native UI). - // If native never had a session (e.g. force refresh didn't work), don't sign out JS. - const sessionCheck = (await ClerkExpo.getSession?.()) as NativeSessionResult | null; - const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id); - - if (!hasNativeSession && hadNativeSessionBefore) { - // Clear local state immediately for instant UI feedback - setNativeUser(null); - - // Clear native session explicitly (may already be cleared, but ensure it) - try { - await ClerkExpo.signOut?.(); - } catch (e) { - if (__DEV__) { - console.warn('[UserButton] Native signOut error (may already be signed out):', e); - } - } - - // Sign out from JS SDK to update isSignedIn state - if (clerk?.signOut) { - try { - await clerk.signOut(); - } catch (e) { - if (__DEV__) { - console.warn('[UserButton] JS SDK signOut error:', e); - } - } - } - } - } catch (error) { - if (__DEV__) { - console.error('[UserButton] presentUserProfile failed:', error); - } - } finally { - presentingRef.current = false; - } - }; - - // Show fallback when native modules aren't available - if (!isNativeSupported || !ClerkExpo) { - return ( - - ? - - ); +export function UserButton() { + if (!isNativeSupported || !NativeClerkUserButtonView) { + return null; } - return ( - void handlePress()} - style={styles.button} - > - {user?.imageUrl ? ( - - ) : ( - - {getInitials(user)} - - )} - - ); + return ; } const styles = StyleSheet.create({ - button: { - width: '100%', - height: '100%', - overflow: 'hidden', - }, - avatar: { - flex: 1, - backgroundColor: '#6366f1', - justifyContent: 'center', - alignItems: 'center', - }, - avatarImage: { - width: '100%', - height: '100%', - }, - avatarText: { - color: 'white', - fontSize: 14, - fontWeight: '600', - }, - text: { - fontSize: 14, - color: '#666', + // React Native/Yoga does not infer the intrinsic size of this native host view. + host: { + width: 36, + height: 36, }, }); diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo/src/native/UserProfileView.tsx index f102cdee2b7..1263569d5c2 100644 --- a/packages/expo/src/native/UserProfileView.tsx +++ b/packages/expo/src/native/UserProfileView.tsx @@ -1,10 +1,9 @@ -import { useClerk } from '@clerk/react'; -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import type { StyleProp, ViewStyle } from 'react-native'; import { StyleSheet, Text, View } from 'react-native'; import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView'; -import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; +import { isNativeSupported } from '../utils/native-module'; /** * Props for the UserProfileView component. @@ -13,17 +12,22 @@ export interface UserProfileViewProps { /** * Whether the inline profile view shows a dismiss button. * - * This controls the native view's built-in dismiss button — it does not - * present a modal. To present a native modal, use the `useUserProfileModal()` hook. + * This controls the native view's built-in dismiss button. It does not present + * a modal; render `UserProfileView` inside your own `Modal`, sheet, or route. * - * @default false + * @default true */ - isDismissable?: boolean; + isDismissible?: boolean; /** * Style applied to the container view. */ style?: StyleProp; + + /** + * Called when the user dismisses the native profile view. + */ + onDismiss?: () => void; } /** @@ -33,7 +37,7 @@ export interface UserProfileViewProps { * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android * - * To present the profile as a native modal, use the `useUserProfileModal()` hook instead. + * To present the profile, render it inside your own `Modal`, sheet, or route. * * Sign-out is detected automatically and synced with the JS SDK. Use `useAuth()` in a * `useEffect` to react to sign-out. @@ -56,37 +60,14 @@ export interface UserProfileViewProps { * * @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation */ -export function UserProfileView({ isDismissable = false, style }: UserProfileViewProps) { - const clerk = useClerk(); - const signOutTriggered = useRef(false); - +export function UserProfileView({ isDismissible = true, style, onDismiss }: UserProfileViewProps) { const handleProfileEvent = useCallback( - async (event: { nativeEvent: { type: string; data: string } }) => { - const { type } = event.nativeEvent; - - if (type === 'signedOut' && !signOutTriggered.current) { - signOutTriggered.current = true; - - try { - await ClerkExpo?.signOut(); - } catch (e) { - if (__DEV__) { - console.warn('[UserProfileView] Native signOut error (may already be signed out):', e); - } - } - - if (clerk?.signOut) { - try { - await clerk.signOut(); - } catch (err) { - if (__DEV__) { - console.warn('[UserProfileView] JS SDK sign out error:', err); - } - } - } + (event: { nativeEvent: { type: string } }) => { + if (event.nativeEvent.type === 'dismissed') { + onDismiss?.(); } }, - [clerk], + [onDismiss], ); if (!isNativeSupported || !NativeClerkUserProfileView) { @@ -104,7 +85,7 @@ export function UserProfileView({ isDismissable = false, style }: UserProfileVie return ( ); diff --git a/packages/expo/src/native/__tests__/AuthView.test.tsx b/packages/expo/src/native/__tests__/AuthView.test.tsx new file mode 100644 index 00000000000..4f7c1582b74 --- /dev/null +++ b/packages/expo/src/native/__tests__/AuthView.test.tsx @@ -0,0 +1,43 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, test, vi } from 'vitest'; + +import { AuthView } from '../AuthView'; + +const mocks = vi.hoisted(() => { + return { + NativeClerkAuthView: vi.fn(() => null), + }; +}); + +vi.mock('../../specs/NativeClerkAuthView', () => { + return { + default: mocks.NativeClerkAuthView, + }; +}); + +vi.mock('../../utils/native-module', () => { + return { + isNativeSupported: true, + }; +}); + +vi.mock('react-native', () => { + return { + Text: ({ children }: { children?: React.ReactNode }) => React.createElement('span', null, children), + View: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children), + }; +}); + +describe('AuthView', () => { + test('calls onDismiss when the native auth view emits dismissed', () => { + const onDismiss = vi.fn(); + + render(); + + const props = mocks.NativeClerkAuthView.mock.calls[0]?.[0]; + props.onAuthEvent({ nativeEvent: { type: 'dismissed' } }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts index 8ccd60b6f2c..b59a8eeb106 100644 --- a/packages/expo/src/native/index.ts +++ b/packages/expo/src/native/index.ts @@ -23,7 +23,7 @@ * * - {@link AuthView} - Authentication flow (sign-in/sign-up), renders inline * - {@link UserProfileView} - User profile and account management, renders inline - * - {@link UserButton} - Avatar button that opens native profile modal + * - {@link UserButton} - Avatar button that opens the native user profile * * @module @clerk/expo/native */ @@ -31,6 +31,5 @@ export { AuthView } from './AuthView'; export type { AuthViewProps, AuthViewMode } from './AuthView.types'; export { UserButton } from './UserButton'; -export type { UserButtonProps } from './UserButton'; export { UserProfileView } from './UserProfileView'; export type { UserProfileViewProps } from './UserProfileView'; diff --git a/packages/expo/src/plugin/withClerkExpo.ts b/packages/expo/src/plugin/withClerkExpo.ts index 0cddd5ff281..4f26423bc37 100644 --- a/packages/expo/src/plugin/withClerkExpo.ts +++ b/packages/expo/src/plugin/withClerkExpo.ts @@ -82,8 +82,8 @@ const withClerkGoogleSignIn: ConfigPlugin = config => { * 1. Configures iOS URL scheme for Google Sign-In (if env var is set) * 2. Adds Android packaging exclusions to resolve dependency conflicts * - * Native modules are registered via react-native.config.js and standard - * React Native autolinking (RCTViewManager / ReactPackage). + * Native modules are registered via Expo Modules autolinking on Android and + * React Native autolinking on iOS (RCTViewManager). */ const withClerkExpo: ConfigPlugin = config => { config = withClerkGoogleSignIn(config); diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 3c683be6c26..f7d0f158614 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -1,14 +1,13 @@ import '../polyfills'; import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react'; -import { useAuth } from '@clerk/react'; import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal'; -import { useEffect, useRef } from 'react'; +import { type MutableRefObject, useEffect, useRef } from 'react'; import { Platform } from 'react-native'; import type { TokenCache } from '../cache/types'; import { CLERK_CLIENT_JWT_KEY } from '../constants'; -import { useNativeAuthEvents } from '../hooks/useNativeAuthEvents'; +import { useNativeClientEvents } from '../hooks/useNativeClientEvents'; import NativeClerkModule from '../specs/NativeClerkModule'; import { tokenCache as defaultTokenCache } from '../token-cache'; import { isNative, isWeb } from '../utils/runtime'; @@ -54,138 +53,163 @@ const SDK_METADATA = { version: PACKAGE_VERSION, }; +type SyncableClerkInstance = { + addListener?: (listener: (payload?: unknown) => void, options?: { skipInitialEmit?: boolean }) => () => void; + addOnLoaded?: (listener: () => void) => void; + client?: { lastActiveSessionId?: string | null } | null; + loaded?: boolean; + session?: { id?: string | null } | null; + setActive?: (params: { session: string }) => void | Promise; + __internal_reloadInitialResources?: () => void | Promise; +}; + +async function waitForNativeClientToken(): Promise { + const ClerkExpo = NativeClerkModule; + if (!ClerkExpo?.getClientToken) { + return null; + } + + const maxAttempts = 30; + const intervalMs = 100; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const nativeClientToken = await ClerkExpo.getClientToken(); + if (nativeClientToken) { + return nativeClientToken; + } + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + + return null; +} + +async function syncClientTokenToCache(tokenCache: TokenCache | undefined, clientToken: string | null): Promise { + if (clientToken) { + await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, clientToken); + } else { + await tokenCache?.clearToken?.(CLERK_CLIENT_JWT_KEY); + } +} + +async function syncNativeClientToJs({ + clerkInstance, + tokenCache, +}: { + clerkInstance: SyncableClerkInstance; + tokenCache: TokenCache | undefined; +}): Promise { + const nativeClientToken = await waitForNativeClientToken(); + const effectiveTokenCache = tokenCache ?? defaultTokenCache; + + await syncClientTokenToCache(effectiveTokenCache, nativeClientToken); + if (typeof clerkInstance.__internal_reloadInitialResources === 'function') { + await clerkInstance.__internal_reloadInitialResources(); + } + + const nativeActiveSessionId = clerkInstance.client?.lastActiveSessionId; + const jsActiveSessionId = clerkInstance.session?.id; + + if ( + nativeActiveSessionId && + nativeActiveSessionId !== jsActiveSessionId && + typeof clerkInstance.setActive === 'function' + ) { + await clerkInstance.setActive({ session: nativeActiveSessionId }); + } +} + /** - * Syncs JS SDK auth state to the native Clerk SDK. - * - * When a user authenticates via the JS SDK (custom sign-in forms, useSignIn, etc.) - * rather than through native ``, the native SDK doesn't know about the - * session. This component watches for JS auth state changes and pushes the bearer - * token to the native SDK so native components (UserButton, UserProfileView) work. + * Syncs JS SDK client changes to the native Clerk SDK so native components + * (UserButton, UserProfileView) stay in sync after JS-owned resource changes. * - * Must be rendered inside `ClerkReactProvider` so `useAuth()` has access to context. + * Must be rendered inside `ClerkReactProvider` so the Clerk instance has loaded + * resources to emit. */ -function NativeSessionSync({ +function NativeClientSync({ + clerkInstance, + isSyncingNativeClientToJsRef, publishableKey, tokenCache, }: { + clerkInstance: SyncableClerkInstance | null | undefined; + isSyncingNativeClientToJsRef: MutableRefObject; publishableKey: string; tokenCache: TokenCache | undefined; -}) { - const { isSignedIn, isLoaded } = useAuth(); - const hasSyncedRef = useRef(false); +}): null { + const isRefreshingNativeFromJsRef = useRef(false); // Use the provided tokenCache, falling back to the default SecureStore cache const effectiveTokenCache = tokenCache ?? defaultTokenCache; useEffect(() => { - if (!isSignedIn) { - hasSyncedRef.current = false; - - // Only call native signOut when Clerk has fully loaded and confirmed - // the user is actually signed out. Without this check, a JS reload - // (e.g. pressing R in Expo) triggers signOut during the loading phase - // (when isSignedIn is undefined), which revokes the session server-side - // and clears all keychain items, forcing the user to log in again. - if (isLoaded) { - const ClerkExpo = NativeClerkModule; - if (ClerkExpo?.signOut) { - void ClerkExpo.signOut().catch((error: unknown) => { - if (__DEV__) { - console.warn('[NativeSessionSync] Failed to clear native session:', error); - } - }); - } - } - + if (!clerkInstance || typeof clerkInstance.addListener !== 'function') { return; } - if (hasSyncedRef.current) { - return; - } - - const syncToNative = async () => { - try { - const ClerkExpo = NativeClerkModule; - if (!ClerkExpo?.configure || !ClerkExpo?.getSession) { + return clerkInstance.addListener( + () => { + if (isSyncingNativeClientToJsRef.current || isRefreshingNativeFromJsRef.current) { return; } - // Check if native already has a session (e.g. auth via AuthView or initial load) - const nativeSession = (await ClerkExpo.getSession()) as { - sessionId?: string; - session?: { id: string }; - } | null; - const hasNativeSession = !!(nativeSession?.sessionId || nativeSession?.session?.id); + isRefreshingNativeFromJsRef.current = true; - if (hasNativeSession) { - hasSyncedRef.current = true; - return; - } + const refreshNativeFromJsClient = async (): Promise => { + const ClerkExpo = NativeClerkModule; + if (!ClerkExpo) { + return; + } - // Read the JS SDK's client JWT and push it to the native SDK - const bearerToken = (await effectiveTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; - if (bearerToken) { - await ClerkExpo.configure(publishableKey, bearerToken); - hasSyncedRef.current = true; - } - } catch (error) { - if (__DEV__) { - console.warn('[NativeSessionSync] Failed to sync JS session to native:', error); - } - } - }; + const bearerToken = (await effectiveTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + if (bearerToken) { + // configure writes the token and refreshes native client state. + await ClerkExpo.configure(publishableKey, bearerToken); + } else { + // No token to push; ask native to reload its current client. + await ClerkExpo.refreshClient(); + } + }; - void syncToNative(); - }, [isSignedIn, isLoaded, publishableKey, effectiveTokenCache]); + void refreshNativeFromJsClient() + .catch((error: unknown) => { + if (__DEV__) { + console.warn('[NativeClientSync] Failed to refresh native client from JS client change:', error); + } + }) + .finally(() => { + isRefreshingNativeFromJsRef.current = false; + }); + }, + { skipInitialEmit: true }, + ); + }, [clerkInstance, effectiveTokenCache, isSyncingNativeClientToJsRef, publishableKey]); return null; } -export function ClerkProvider(props: ClerkProviderProps): JSX.Element { - const { - children, - tokenCache, - publishableKey, - proxyUrl, - domain, - __experimental_passkeys, - experimental, - __experimental_resourceCache, - ...rest - } = props; - const pk = publishableKey; - - // Track pending native session to sync after clerk loads - const pendingNativeSessionRef = useRef(null); +function useNativeSessionBootstrap({ + isSyncingNativeClientToJsRef, + publishableKey, + tokenCache, + clerkInstance, +}: { + isSyncingNativeClientToJsRef: MutableRefObject; + publishableKey: string; + tokenCache: TokenCache | undefined; + clerkInstance: SyncableClerkInstance | null | undefined; +}) { const initStartedRef = useRef(false); const sessionSyncedRef = useRef(false); - // Reset refs when publishable key changes (hot-swap support) + const isMountedRef = useRef(true); + useEffect(() => { - pendingNativeSessionRef.current = null; initStartedRef.current = false; sessionSyncedRef.current = false; - }, [pk]); + }, [publishableKey]); - // Get the Clerk instance for syncing - const clerkInstance = isNative() - ? getClerkInstance({ - publishableKey: pk, - tokenCache, - proxyUrl, - domain, - __experimental_passkeys, - __experimental_resourceCache, - }) - : null; - - // Track whether the component is still mounted - const isMountedRef = useRef(true); - - // Configure native Clerk SDK and set up session sync callback useEffect(() => { isMountedRef.current = true; - if ((Platform.OS === 'ios' || Platform.OS === 'android') && pk && !initStartedRef.current) { + if ((Platform.OS === 'ios' || Platform.OS === 'android') && publishableKey && !initStartedRef.current) { initStartedRef.current = true; const configureNativeClerk = async () => { @@ -193,8 +217,6 @@ export function ClerkProvider(props: ClerkProviderProps(props: ClerkProviderProps setTimeout(resolve, POLL_INTERVAL_MS)); - } + await ClerkExpo.configure(publishableKey, bearerToken); if (!isMountedRef.current) { return; } - if (sessionId && clerkInstance) { - pendingNativeSessionRef.current = sessionId; - - // Wait for clerk to be loaded before syncing - const clerkAny = clerkInstance as any; - + if (clerkInstance) { const waitForLoad = (): Promise => { return new Promise(resolve => { - if (clerkAny.loaded) { + if (clerkInstance.loaded) { resolve(); - } else if (typeof clerkAny.addOnLoaded === 'function') { - clerkAny.addOnLoaded(() => resolve()); + } else if (typeof clerkInstance.addOnLoaded === 'function') { + clerkInstance.addOnLoaded(() => resolve()); } else { if (__DEV__) { console.warn('[ClerkProvider] Clerk instance has no loaded property or addOnLoaded method'); @@ -268,25 +255,16 @@ export function ClerkProvider(props: ClerkProviderProps s.id === pendingSession, - ); - if (!sessionInClient && typeof clerkAny.__internal_reloadInitialResources === 'function') { - await clerkAny.__internal_reloadInitialResources(); - } - + isSyncingNativeClientToJsRef.current = true; try { - await clerkInstance.setActive({ session: pendingSession }); - } catch (err) { - if (__DEV__) { - console.error(`[ClerkProvider] Failed to sync native session:`, err); - } + await syncNativeClientToJs({ + clerkInstance, + tokenCache, + }); + } finally { + isSyncingNativeClientToJsRef.current = false; } } } @@ -314,63 +292,75 @@ export function ClerkProvider(props: ClerkProviderProps { isMountedRef.current = false; }; - }, [pk, clerkInstance]); + }, [publishableKey, tokenCache, clerkInstance, isSyncingNativeClientToJsRef]); - // Listen for native auth state changes and sync to JS SDK - const { nativeAuthState } = useNativeAuthEvents(); + return isMountedRef; +} + +export function ClerkProvider(props: ClerkProviderProps): JSX.Element { + const { + children, + tokenCache, + publishableKey, + proxyUrl, + domain, + __experimental_passkeys, + experimental, + __experimental_resourceCache, + ...rest + } = props; + const pk = publishableKey; + + const clerkInstance = isNative() + ? getClerkInstance({ + publishableKey: pk, + tokenCache, + proxyUrl, + domain, + __experimental_passkeys, + __experimental_resourceCache, + }) + : null; + + const isSyncingNativeClientToJsRef = useRef(false); + const isMountedRef = useNativeSessionBootstrap({ + isSyncingNativeClientToJsRef, + publishableKey: pk, + tokenCache, + clerkInstance, + }); + + // Listen for native client events and reload JS from the client source of truth. + const { nativeClientEvent } = useNativeClientEvents(); useEffect(() => { - if (!nativeAuthState || !clerkInstance) { + if (!nativeClientEvent || !clerkInstance) { return; } - const syncNativeAuthToJs = async () => { + const syncNativeClientStateToJs = async () => { try { - if (nativeAuthState.type === 'signedIn' && nativeAuthState.sessionId && clerkInstance.setActive) { - // Copy the native client's bearer token to the JS SDK's token cache - // so API requests use the native client (which has the session). - const ClerkExpo = NativeClerkModule; - if (ClerkExpo?.getClientToken) { - const nativeClientToken = await ClerkExpo.getClientToken(); - if (nativeClientToken) { - const effectiveTokenCache = tokenCache ?? defaultTokenCache; - await effectiveTokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); - } - } - - // Ensure the session exists in the client before calling setActive - const sessionInClient = clerkInstance.client?.sessions?.some( - (s: { id: string }) => s.id === nativeAuthState.sessionId, - ); - if (!sessionInClient) { - const clerkAny = clerkInstance as any; - if (typeof clerkAny.__internal_reloadInitialResources === 'function') { - await clerkAny.__internal_reloadInitialResources(); - } - if (!isMountedRef.current) { - return; - } - } - - if (!isMountedRef.current) { - return; - } - await clerkInstance.setActive({ session: nativeAuthState.sessionId }); - } else if (nativeAuthState.type === 'signedOut' && clerkInstance.signOut) { - if (!isMountedRef.current) { - return; - } - await clerkInstance.signOut(); + if (!isMountedRef.current) { + return; + } + isSyncingNativeClientToJsRef.current = true; + try { + await syncNativeClientToJs({ + clerkInstance, + tokenCache, + }); + } finally { + isSyncingNativeClientToJsRef.current = false; } } catch (error) { if (__DEV__) { - console.error(`[ClerkProvider] Failed to sync native auth state:`, error); + console.error(`[ClerkProvider] Failed to sync native client state:`, error); } } }; - void syncNativeAuthToJs(); - }, [nativeAuthState, clerkInstance]); + void syncNativeClientStateToJs(); + }, [nativeClientEvent, clerkInstance, tokenCache, isMountedRef]); // Needed for `useOAuth` / `useSSO` to work correctly on web — must stay synchronous during render // so the redirect URL is caught before children mount. Resolves to a no-op on native via the @@ -400,7 +390,9 @@ export function ClerkProvider(props: ClerkProviderProps {isNative() && ( - diff --git a/packages/expo/src/specs/NativeClerkAuthView.android.ts b/packages/expo/src/specs/NativeClerkAuthView.android.ts new file mode 100644 index 00000000000..c41bf58a5ea --- /dev/null +++ b/packages/expo/src/specs/NativeClerkAuthView.android.ts @@ -0,0 +1,13 @@ +import { requireNativeView } from 'expo'; +import type { ViewProps } from 'react-native'; + +type AuthEvent = Readonly<{ type: string }>; +type NativeEvent = Readonly<{ nativeEvent: T }>; + +interface NativeProps extends ViewProps { + mode?: string; + isDismissible?: boolean; + onAuthEvent?: (event: NativeEvent) => void; +} + +export default requireNativeView('ClerkAuthView'); diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo/src/specs/NativeClerkAuthView.ts index e4cffd1497d..321734c692b 100644 --- a/packages/expo/src/specs/NativeClerkAuthView.ts +++ b/packages/expo/src/specs/NativeClerkAuthView.ts @@ -5,11 +5,11 @@ import type { HostComponent, ViewProps } from 'react-native'; import type { BubblingEventHandler } from 'react-native/Libraries/Types/CodegenTypes'; /* eslint-enable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */ -type AuthEvent = Readonly<{ type: string; data: string }>; +type AuthEvent = Readonly<{ type: string }>; interface NativeProps extends ViewProps { mode?: string; - isDismissable?: boolean; + isDismissible?: boolean; onAuthEvent?: BubblingEventHandler; } diff --git a/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts b/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts new file mode 100644 index 00000000000..4ad961835a5 --- /dev/null +++ b/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts @@ -0,0 +1,13 @@ +import { requireNativeModule } from 'expo'; + +type NativeMap = Record; + +interface Spec { + configure(params: NativeMap): void; + signIn(params: NativeMap | null): Promise; + createAccount(params: NativeMap | null): Promise; + presentExplicitSignIn(params: NativeMap | null): Promise; + signOut(): Promise; +} + +export default requireNativeModule('ClerkGoogleSignIn'); diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts new file mode 100644 index 00000000000..1ffb278809a --- /dev/null +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -0,0 +1,16 @@ +import { requireNativeModule } from 'expo'; + +type NativeMap = Record; + +interface Spec { + // addListener/removeListeners are present on the iOS RN event emitter module. + // Android uses Expo Modules EventEmitter instead. + addListener?(eventName: string): void; + configure(publishableKey: string, bearerToken: string | null): Promise; + getSession(): Promise; + getClientToken(): Promise; + refreshClient(): Promise; + removeListeners?(count: number): void; +} + +export default requireNativeModule('ClerkExpo'); diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index 1c38d2c1f92..9600dea3835 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -3,12 +3,16 @@ import { TurboModuleRegistry } from 'react-native'; import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypesNamespace'; export interface Spec extends TurboModule { + // Required by NativeEventEmitter for internal native client refresh events. + // This is not part of the public @clerk/expo API. + addListener(eventName: string): void; configure(publishableKey: string, bearerToken: string | null): Promise; - presentAuth(options: UnsafeObject): Promise; - presentUserProfile(options: UnsafeObject): Promise; getSession(): Promise; getClientToken(): Promise; - signOut(): Promise; + refreshClient(): Promise; + // Required by NativeEventEmitter for internal native client refresh events. + // This is not part of the public @clerk/expo API. + removeListeners(count: number): void; } export default TurboModuleRegistry.get('ClerkExpo'); diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.android.ts b/packages/expo/src/specs/NativeClerkUserButtonView.android.ts new file mode 100644 index 00000000000..90db3f4a4dc --- /dev/null +++ b/packages/expo/src/specs/NativeClerkUserButtonView.android.ts @@ -0,0 +1,9 @@ +import { requireNativeView } from 'expo'; +import type { ViewProps } from 'react-native'; + +// Codegen requires an interface declaration in the iOS spec; keep the Android +// view prop shape identical for the shared React wrapper. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface NativeProps extends ViewProps {} + +export default requireNativeView('ClerkUserButtonView'); diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.ts b/packages/expo/src/specs/NativeClerkUserButtonView.ts new file mode 100644 index 00000000000..e5541213487 --- /dev/null +++ b/packages/expo/src/specs/NativeClerkUserButtonView.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */ +// These deep imports from react-native internals are required by codegen. +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; +import type { HostComponent, ViewProps } from 'react-native'; +/* eslint-enable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */ + +// Codegen requires an interface declaration here; a type alias fails Android codegen. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface NativeProps extends ViewProps {} + +export default codegenNativeComponent('ClerkUserButtonView') as HostComponent; diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.android.ts b/packages/expo/src/specs/NativeClerkUserProfileView.android.ts new file mode 100644 index 00000000000..7adcbd11dcf --- /dev/null +++ b/packages/expo/src/specs/NativeClerkUserProfileView.android.ts @@ -0,0 +1,12 @@ +import { requireNativeView } from 'expo'; +import type { ViewProps } from 'react-native'; + +type ProfileEvent = Readonly<{ type: string }>; +type NativeEvent = Readonly<{ nativeEvent: T }>; + +interface NativeProps extends ViewProps { + isDismissible?: boolean; + onProfileEvent?: (event: NativeEvent) => void; +} + +export default requireNativeView('ClerkUserProfileView'); diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo/src/specs/NativeClerkUserProfileView.ts index a6096769738..289dabf4d70 100644 --- a/packages/expo/src/specs/NativeClerkUserProfileView.ts +++ b/packages/expo/src/specs/NativeClerkUserProfileView.ts @@ -5,10 +5,10 @@ import type { HostComponent, ViewProps } from 'react-native'; import type { BubblingEventHandler } from 'react-native/Libraries/Types/CodegenTypes'; /* eslint-enable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */ -type ProfileEvent = Readonly<{ type: string; data: string }>; +type ProfileEvent = Readonly<{ type: string }>; interface NativeProps extends ViewProps { - isDismissable?: boolean; + isDismissible?: boolean; onProfileEvent?: BubblingEventHandler; } diff --git a/packages/expo/src/token-cache/index.ts b/packages/expo/src/token-cache/index.ts index 0a577cbe577..ba9e46baea7 100644 --- a/packages/expo/src/token-cache/index.ts +++ b/packages/expo/src/token-cache/index.ts @@ -30,6 +30,9 @@ const createTokenCache = (): TokenCache => { saveToken: (key: string, token: string) => { return SecureStore.setItemAsync(key, token, secureStoreOpts); }, + clearToken: (key: string) => { + return SecureStore.deleteItemAsync(key, secureStoreOpts); + }, }; }; @@ -43,6 +46,7 @@ const createTokenCache = (): TokenCache => { * To implement your own token cache, create an object that implements the `TokenCache` interface: * - `getToken(key: string): Promise` * - `saveToken(key: string, token: string): Promise` + * - `clearToken(key: string): void | Promise` (optional) * * @type {TokenCache | undefined} Object with `getToken` and `saveToken` methods, undefined on web */