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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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