Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/hosted-navigation-native-views.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@clerk/expo': minor
---

Support pushing the native `UserProfileView` and `AuthView` onto your app's own navigation stack.

New optional `onHostBack` prop shows a back button on the component's root screen and calls you when it is tapped. The component keeps its own navigation chrome, so screen titles, back buttons, swipe-back, and transitions inside the component stay native — hide your route's header and pop your route from the callback:

```tsx
<Stack.Screen options={{ headerShown: false }} />
<UserProfileView isDismissible={false} onHostBack={() => router.back()} />
```

The component never leaves the route on its own, so react to auth state for flow completion — either swap the content in place or pop the route.

Existing usage is unaffected: the prop is optional, and the components render exactly as before without it. Requires the corresponding clerk-ios and clerk-android SDK releases.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
27 changes: 26 additions & 1 deletion integration/templates/expo-native/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ClerkProvider, useAuth, useUser } from '@clerk/expo';
import { AuthView, UserButton } from '@clerk/expo/native';
import { AuthView, UserButton, UserProfileView } from '@clerk/expo/native';
import { tokenCache } from '@clerk/expo/token-cache';
import { useState } from 'react';
import { Button, Modal, StyleSheet, Text, View } from 'react-native';
Expand All @@ -18,6 +18,7 @@ function NativeBuildFixture() {
const { isLoaded, isSignedIn, signOut } = useAuth({ treatPendingAsSignedOut: false });
const { user } = useUser();
const [isAuthOpen, setIsAuthOpen] = useState(false);
const [isProfileOpen, setIsProfileOpen] = useState(false);
const [e2eStatus, setE2eStatus] = useState<string | null>(null);

return (
Expand All @@ -38,6 +39,13 @@ function NativeBuildFixture() {
{!isSignedIn && <JsSignInForm onStatus={setE2eStatus} />}
{isSignedIn && <E2EControls onStatus={setE2eStatus} />}
{e2eStatus && <Text testID='e2e-status'>{e2eStatus}</Text>}
{isSignedIn && (
<Button
testID='open-embedded-profile-button'
title='Open embedded profile'
onPress={() => setIsProfileOpen(true)}
/>
)}
{isSignedIn && (
<Button
testID='sign-out-button'
Expand All @@ -46,6 +54,15 @@ function NativeBuildFixture() {
/>
)}

{isProfileOpen && (
<View style={styles.embeddedProfile}>
<UserProfileView
isDismissible={false}
onHostBack={() => setIsProfileOpen(false)}
/>
</View>
)}

<Modal
animationType='slide'
visible={isAuthOpen}
Expand Down Expand Up @@ -86,6 +103,14 @@ const styles = StyleSheet.create({
justifyContent: 'center',
padding: 24,
},
embeddedProfile: {
backgroundColor: '#FFFFFF',
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0,
},
customLogo: {
backgroundColor: '#6C47FF',
borderRadius: 8,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Embedded UserProfileView (onHostBack): internal navigation stays native,
# and the host-supplied root back button closes the screen from JS.
appId: com.clerk.exponativebuildfixture
name: Embedded UserProfileView host back round trip
---
- runFlow: subflows/open-app.yaml
- tapOn:
id: 'open-auth-view-button'
- runFlow: subflows/sign-in-email-password.yaml
- runFlow: subflows/assert-signed-in.yaml
- tapOn:
id: 'open-embedded-profile-button'
- extendedWaitUntil:
visible: 'Security'
timeout: 20000
- tapOn:
text: 'Security'
- extendedWaitUntil:
visible: 'Password|Passkeys|Two-step verification|Active devices'
timeout: 15000
# Both back buttons read 'Back' (internal id 'BackButton', host root id
# 'chevron.backward') but only one exists at a time: this tap pops Clerk's
# internal stack, the next one is the host chevron firing onHostBack.
- tapOn: 'Back'
- extendedWaitUntil:
visible: 'Security'
timeout: 15000
- tapOn: 'Back'
- extendedWaitUntil:
visible:
id: 'open-embedded-profile-button'
timeout: 15000
- tapOn:
id: 'sign-out-button'
- runFlow: subflows/assert-signed-out.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:OptIn(FrameworkIntegrationApi::class)

package expo.modules.clerk

import android.content.Context
Expand All @@ -15,10 +17,12 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import com.clerk.api.Clerk
import com.clerk.api.FrameworkIntegrationApi
import com.clerk.api.ui.ClerkDesign
import com.clerk.api.ui.ClerkTheme
import com.clerk.ui.auth.AuthMode
import com.clerk.ui.auth.AuthView
import com.clerk.ui.navigation.ClerkHostBackActionProvider
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
Expand All @@ -38,6 +42,7 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo
var mode: String? = null
var logoView: View? = null
private set
var hostBackButton: Boolean = false

private var logoWidth = 0
private var logoHeight = 0
Expand All @@ -53,6 +58,7 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo
}

private val onAuthEvent by EventDispatcher()
private val onHostBack by EventDispatcher()

init {
// At cold start, ClerkExpoModule.configure() may run before React's
Expand Down Expand Up @@ -83,8 +89,17 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo

@Composable
override fun Content() {
debugLog(TAG, "setupView - mode: $mode, isDismissible: $isDismissible, activity: $activity")
debugLog(TAG, "setupView - mode: $mode, isDismissible: $isDismissible, hostBackButton: $hostBackButton, activity: $activity")

if (hostBackButton) {
ClerkHostBackActionProvider(onHostBack = { onHostBack(mapOf()) }) { AuthContent() }
} else {
AuthContent()
}
}

@Composable
private fun AuthContent() {
AuthView(
modifier = Modifier.fillMaxSize(),
clerkTheme = authTheme(),
Expand Down Expand Up @@ -167,7 +182,7 @@ class ClerkAuthViewModule : Module() {
Name("ClerkAuthView")

View(ClerkAuthNativeView::class) {
Events("onAuthEvent")
Events("onAuthEvent", "onHostBack")

GroupView<ClerkAuthNativeView> {
AddChildView<View> { parent, child, _ ->
Expand Down Expand Up @@ -201,10 +216,13 @@ class ClerkAuthViewModule : Module() {
view.logoMaxHeight = logoMaxHeight
}

Prop("hostBackButton") { view: ClerkAuthNativeView, hostBackButton: Boolean ->
view.hostBackButton = hostBackButton
}

OnViewDidUpdateProps { view: ClerkAuthNativeView ->
view.setupView()
}

}
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
@file:OptIn(FrameworkIntegrationApi::class)

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.api.FrameworkIntegrationApi
import com.clerk.ui.navigation.ClerkHostBackActionProvider
import com.clerk.ui.userprofile.UserProfileView
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
Expand All @@ -25,7 +27,9 @@ private fun debugLog(tag: String, message: String) {
class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
// clerk-android UserProfileView dismissibility is controlled by its onDismiss callback.
var isDismissible: Boolean = true
var hostBackButton: Boolean = false
private val onProfileEvent by EventDispatcher()
private val onHostBack by EventDispatcher()

private val viewModelStoreOwner = object : ViewModelStoreOwner {
private val store = ViewModelStore()
Expand All @@ -40,15 +44,24 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle

@Composable
override fun Content() {
debugLog(TAG, "setupView - isDismissible: $isDismissible")
debugLog(TAG, "setupView - isDismissible: $isDismissible, hostBackButton: $hostBackButton")

if (hostBackButton) {
ClerkHostBackActionProvider(onHostBack = { onHostBack(mapOf()) }) { ProfileView() }
} else {
ProfileView()
}
}

@Composable
private fun ProfileView() {
UserProfileView(
clerkTheme = Clerk.customTheme,
isDismissible = isDismissible,
onDismiss = {
debugLog(TAG, "Profile dismissed")
sendEvent("dismissed")
}
},
)
}

Expand All @@ -62,12 +75,16 @@ class ClerkUserProfileViewModule : Module() {
Name("ClerkUserProfileView")

View(ClerkUserProfileNativeView::class) {
Events("onProfileEvent")
Events("onProfileEvent", "onHostBack")

Prop("isDismissible") { view: ClerkUserProfileNativeView, isDismissible: Boolean ->
view.isDismissible = isDismissible
}

Prop("hostBackButton") { view: ClerkUserProfileNativeView, hostBackButton: Boolean ->
view.hostBackButton = hostBackButton
}

OnViewDidUpdateProps { view: ClerkUserProfileNativeView ->
view.setupView()
}
Expand Down
21 changes: 20 additions & 1 deletion packages/expo/ios/ClerkAuthNativeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ public class ClerkAuthNativeView: ClerkNativeViewHost {
private var currentLogoMaxHeight: CGFloat?
private let logoState = ClerkInlineAuthLogoState()
private var logoBoundsObservation: NSKeyValueObservation?
private var currentHostBackButton: Bool = false
private var didSendDismiss = false

let onAuthEvent = EventDispatcher()
let onHostBack = EventDispatcher()

func setMode(_ mode: String?) {
let newMode = mode ?? "signInOrUp"
Expand All @@ -31,6 +33,13 @@ public class ClerkAuthNativeView: ClerkNativeViewHost {
setNeedsHostedViewUpdate()
}

func setHostBackButton(_ hostBackButton: Bool?) {
let newHostBackButton = hostBackButton ?? false
guard newHostBackButton != currentHostBackButton else { return }
currentHostBackButton = newHostBackButton
setNeedsHostedViewUpdate()
}

private func sendAuthEvent(type: ClerkNativeViewEvent) {
onAuthEvent(["type": type.rawValue])
}
Expand Down Expand Up @@ -98,11 +107,16 @@ public class ClerkAuthNativeView: ClerkNativeViewHost {
}

override func makeHostedController() -> UIViewController? {
let hostBackAction: (() -> Void)? = currentHostBackButton
? { [weak self] in self?.onHostBack([:]) }
: nil

return ClerkNativeBridge.shared.makeAuthViewController(
mode: currentMode,
dismissible: currentDismissible,
logoState: logoState,
logoMaxHeight: currentLogoMaxHeight,
hostBackAction: hostBackAction,
onEvent: { [weak self] event, _ in
if event == .dismissed {
self?.sendDismissIfNeeded()
Expand All @@ -117,7 +131,7 @@ public class ClerkAuthViewModule: Module {
Name("ClerkAuthView")

View(ClerkAuthNativeView.self) {
Events("onAuthEvent")
Events("onAuthEvent", "onHostBack")

Prop("mode") { (view: ClerkAuthNativeView, mode: String?) in
view.setMode(mode)
Expand All @@ -130,6 +144,11 @@ public class ClerkAuthViewModule: Module {
Prop("logoMaxHeight") { (view: ClerkAuthNativeView, logoMaxHeight: CGFloat?) in
view.setLogoMaxHeight(logoMaxHeight)
}

Prop("hostBackButton") { (view: ClerkAuthNativeView, hostBackButton: Bool?) in
view.setHostBackButton(hostBackButton)
}

}
}
}
Loading
Loading