diff --git a/.changeset/expo-native-custom-pages.md b/.changeset/expo-native-custom-pages.md
new file mode 100644
index 00000000000..a24f92def1c
--- /dev/null
+++ b/.changeset/expo-native-custom-pages.md
@@ -0,0 +1,29 @@
+---
+'@clerk/expo': minor
+---
+
+Add custom user profile pages to the native `UserProfileView` and `UserButton` components. Use `content` to render a React Native screen or `href` to open an external URL.
+
+```tsx
+import { UserButton, UserProfileView } from '@clerk/expo/native';
+import type { UserProfileCustomPage } from '@clerk/expo/native';
+
+const customPages: UserProfileCustomPage[] = [
+ {
+ path: 'api-keys',
+ label: 'API keys',
+ icon: 'key',
+ content: ,
+ },
+ {
+ path: 'docs',
+ label: 'Docs',
+ icon: 'book',
+ href: 'https://clerk.com/docs',
+ },
+];
+
+;
+
+;
+```
diff --git a/integration/templates/expo-native/App.tsx b/integration/templates/expo-native/App.tsx
index b2cb3d42a71..78fef6a0ea8 100644
--- a/integration/templates/expo-native/App.tsx
+++ b/integration/templates/expo-native/App.tsx
@@ -23,6 +23,22 @@ function NativeBuildFixture() {
+ Rehosted RN body
+
+ ),
+ },
+ ]}
isDismissible={false}
onHostBack={() => setIsProfileOpen(false)}
/>
@@ -120,6 +136,16 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
+ customPage: {
+ alignItems: 'center',
+ flex: 1,
+ justifyContent: 'center',
+ padding: 24,
+ },
+ customPageText: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
header: {
alignItems: 'center',
flexDirection: 'row',
diff --git a/integration/tests/expo-native/flows/user-profile-custom-pages.yaml b/integration/tests/expo-native/flows/user-profile-custom-pages.yaml
new file mode 100644
index 00000000000..0b408084b51
--- /dev/null
+++ b/integration/tests/expo-native/flows/user-profile-custom-pages.yaml
@@ -0,0 +1,45 @@
+# A custom page's React Native content is mounted as a child of the native host
+# and rehosted into the destination its row pushes, then survives the trip back.
+appId: com.clerk.exponativebuildfixture
+name: UserProfileView renders a custom page
+---
+- 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'
+# Retrying wait: the row list lands a frame after the native profile paints.
+- extendedWaitUntil:
+ visible: 'E2E Custom Page'
+ timeout: 20000
+- tapOn:
+ text: 'E2E Custom Page'
+- extendedWaitUntil:
+ visible: 'Rehosted RN body'
+ timeout: 15000
+# The Android destination is a bare AndroidView with no back chrome, unlike the
+# iOS page which clerk-ios pushes onto its own NavigationStack.
+- runFlow:
+ when:
+ platform: iOS
+ commands:
+ - tapOn: 'Back'
+- runFlow:
+ when:
+ platform: Android
+ commands:
+ - back
+- extendedWaitUntil:
+ visible: 'E2E Custom Page'
+ timeout: 15000
+# The host chevron firing onHostBack, which reads 'Back' on both platforms.
+- tapOn: 'Back'
+- extendedWaitUntil:
+ visible:
+ id: 'open-embedded-profile-button'
+ timeout: 15000
+- tapOn:
+ id: 'sign-out-button'
+- runFlow: subflows/assert-signed-out.yaml
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
index da39f566025..9aea1bb96ce 100644
--- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
@@ -2,6 +2,7 @@ package expo.modules.clerk
import android.content.Context
import android.content.ContextWrapper
+import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -91,6 +92,21 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte
protected open fun onHostDetachedFromWindow() {}
+ protected fun layoutAndroidViewHandler(view: View) {
+ view.post {
+ val holder = view.parent as? View ?: return@post
+ val handler = holder.parent as? View ?: return@post
+ val owner = handler.parent as? View ?: return@post
+
+ // AndroidView's handler can enter the hierarchy after its Compose owner was laid out.
+ handler.measure(
+ View.MeasureSpec.makeMeasureSpec(owner.width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(owner.height, View.MeasureSpec.EXACTLY),
+ )
+ handler.layout(0, 0, owner.width, owner.height)
+ }
+ }
+
@Composable
protected abstract fun Content()
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
index 68ca3c5184a..7047d5a4db4 100644
--- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
@@ -1,18 +1,32 @@
package expo.modules.clerk
import android.content.Context
+import android.view.View
+import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
import com.clerk.api.Clerk
+import com.clerk.ui.userprofile.custom.LocalUserProfileCustomNavigator
+import com.clerk.ui.userprofile.custom.UserProfileCustomNavigator
+import com.clerk.ui.userprofile.custom.UserProfileCustomRow
import com.clerk.ui.userbutton.UserButton
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
+import expo.modules.kotlin.viewevent.EventDispatcher
class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
+ var customPagesJson: String = "[]"
+ private val customPageViews = mutableListOf()
+ private var customNavigator: UserProfileCustomNavigator? = null
+ private val onCustomPageEvent by EventDispatcher()
+
init {
activity?.let { Clerk.attachActivity(it) }
}
@@ -23,8 +37,68 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
- UserButton(clerkTheme = Clerk.customTheme)
+ UserButton(
+ clerkTheme = Clerk.customTheme,
+ customRows = customRows(),
+ customDestination =
+ if (customPageViews.isEmpty()) null
+ else { routeKey -> CustomPageDestination(routeKey) },
+ )
+ }
+ }
+
+ fun addCustomPageView(view: View, index: Int) {
+ (view.parent as? ViewGroup)?.removeView(view)
+ customPageViews.add(index.coerceIn(0, customPageViews.size), view)
+ setupView()
+ }
+
+ fun removeCustomPageView(view: View) {
+ customPageViews.remove(view)
+ (view.parent as? ViewGroup)?.removeView(view)
+ setupView()
+ }
+
+ fun customPageViewAt(index: Int): View? = customPageViews.getOrNull(index)
+
+ fun customPageCount(): Int = customPageViews.size
+
+ fun navigateCustomPage(action: String, routeKey: String?) {
+ when (action) {
+ "back" -> customNavigator?.navigateBack()
+ "popToRoot" -> customNavigator?.popToRoot()
+ "push" -> routeKey?.let { customNavigator?.push(it) }
+ }
+ }
+
+ @Composable
+ private fun CustomPageDestination(routeKey: String) {
+ customNavigator = LocalUserProfileCustomNavigator.current
+ val rows = customRows()
+ val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return
+
+ LaunchedEffect(routeKey) {
+ layoutAndroidViewHandler(view)
+ sendCustomPageEvent("presented", routeKey)
}
+ DisposableEffect(routeKey) {
+ onDispose { sendCustomPageEvent("dismissed", routeKey) }
+ }
+
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = {
+ (view.parent as? ViewGroup)?.removeView(view)
+ view
+ },
+ )
+ }
+
+ private fun customRows(): List =
+ runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
+
+ private fun sendCustomPageEvent(type: String, path: String) {
+ onCustomPageEvent(mapOf("type" to type, "path" to path))
}
}
@@ -32,6 +106,30 @@ class ClerkUserButtonViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkUserButtonView")
- View(ClerkUserButtonNativeView::class) {}
+ View(ClerkUserButtonNativeView::class) {
+ Events("onCustomPageEvent")
+
+ GroupView {
+ AddChildView { parent, child, index -> parent.addCustomPageView(child, index) }
+ GetChildCount { parent -> parent.customPageCount() }
+ GetChildViewAt { parent, index -> parent.customPageViewAt(index) }
+ RemoveChildView { parent, child -> parent.removeCustomPageView(child) }
+ RemoveChildViewAt { parent, index -> parent.customPageViewAt(index)?.let(parent::removeCustomPageView) }
+ }
+
+ Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
+ view.customPagesJson = customPages
+ }
+
+ AsyncFunction("navigateCustomPage") {
+ view: ClerkUserButtonNativeView,
+ action: String,
+ routeKey: String? -> view.navigateCustomPage(action, routeKey)
+ }
+
+ OnViewDidUpdateProps { view: ClerkUserButtonNativeView ->
+ view.setupView()
+ }
+ }
}
}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
index 51826181aff..c8a853fa846 100644
--- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
@@ -4,17 +4,33 @@ package expo.modules.clerk
import android.content.Context
import android.util.Log
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+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.ui.R
import com.clerk.ui.navigation.ClerkHostBackActionProvider
import com.clerk.ui.userprofile.UserProfileView
+import com.clerk.ui.userprofile.custom.LocalUserProfileCustomNavigator
+import com.clerk.ui.userprofile.custom.UserProfileCustomRow
+import com.clerk.ui.userprofile.custom.UserProfileCustomRowPlacement
+import com.clerk.ui.userprofile.custom.UserProfileRow
+import com.clerk.ui.userprofile.custom.UserProfileRowIcon
+import com.clerk.ui.userprofile.custom.UserProfileSection
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.viewevent.EventDispatcher
+import org.json.JSONArray
+import org.json.JSONObject
private const val TAG = "ClerkUserProfileViewModule"
@@ -24,11 +40,75 @@ private fun debugLog(tag: String, message: String) {
}
}
+internal fun parseUserProfileCustomPages(customPagesJson: String, customPageCount: Int): List {
+ val pages = JSONArray(customPagesJson)
+ return buildList {
+ for (index in 0 until minOf(pages.length(), customPageCount)) {
+ val page = pages.getJSONObject(index)
+ add(
+ UserProfileCustomRow(
+ routeKey = page.getString("path"),
+ title = page.getString("label"),
+ icon = UserProfileRowIcon.Resource(userProfileCustomRowIcon(page.optString("icon"))),
+ placement = userProfileCustomRowPlacement(page.optJSONObject("placement")),
+ ),
+ )
+ }
+ }
+}
+
+private fun userProfileCustomRowIcon(icon: String): Int =
+ when (icon) {
+ "user" -> R.drawable.ic_user
+ "profile" -> R.drawable.ic_profile
+ "security" -> R.drawable.ic_security
+ "billing" -> R.drawable.ic_credit_card
+ "key" -> R.drawable.ic_key
+ "lock" -> R.drawable.ic_lock
+ "email" -> R.drawable.ic_email
+ "phone" -> R.drawable.ic_phone
+ "add" -> R.drawable.ic_plus
+ "switch" -> R.drawable.ic_switch
+ "users" -> R.drawable.ic_users
+ "warning" -> R.drawable.ic_warning
+ "info" -> R.drawable.ic_information_circle
+ "globe" -> R.drawable.ic_globe
+ "folder" -> R.drawable.ic_folder
+ "book" -> R.drawable.ic_folder
+ else -> R.drawable.ic_cog
+ }
+
+private fun userProfileCustomRowPlacement(placement: JSONObject?): UserProfileCustomRowPlacement {
+ val type = placement?.optString("type")
+ return when (type) {
+ "sectionStart" -> UserProfileCustomRowPlacement.SectionStart(userProfileSection(placement.optString("section")))
+ "before" -> UserProfileCustomRowPlacement.Before(userProfileRow(placement.optString("row")))
+ "after" -> UserProfileCustomRowPlacement.After(userProfileRow(placement.optString("row")))
+ else -> UserProfileCustomRowPlacement.SectionEnd(userProfileSection(placement?.optString("section")))
+ }
+}
+
+private fun userProfileSection(section: String?): UserProfileSection =
+ if (section == "account") UserProfileSection.Account else UserProfileSection.Profile
+
+private fun userProfileRow(row: String): UserProfileRow =
+ when (row) {
+ "security" -> UserProfileRow.Security
+ "switchAccount" -> UserProfileRow.SwitchAccount
+ "addAccount" -> UserProfileRow.AddAccount
+ "signOut" -> UserProfileRow.SignOut
+ else -> UserProfileRow.ManageAccount
+ }
+
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
+ var customPagesJson: String = "[]"
+ private val customPageViews = mutableListOf()
+ private var customNavigator: com.clerk.ui.userprofile.custom.UserProfileCustomNavigator? = null
private val onProfileEvent by EventDispatcher()
+ private val onCustomPageEvent by EventDispatcher()
private val onHostBack by EventDispatcher()
private val viewModelStoreOwner = object : ViewModelStoreOwner {
@@ -57,6 +137,10 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle
private fun ProfileView() {
UserProfileView(
clerkTheme = Clerk.customTheme,
+ customRows = customRows(),
+ customDestination =
+ if (customPageViews.isEmpty()) null
+ else { routeKey -> CustomPageDestination(routeKey) },
isDismissible = isDismissible,
onDismiss = {
debugLog(TAG, "Profile dismissed")
@@ -65,9 +149,70 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle
)
}
+ fun addCustomPageView(view: View, index: Int) {
+ (view.parent as? ViewGroup)?.removeView(view)
+ customPageViews.add(index.coerceIn(0, customPageViews.size), view)
+ setupView()
+ }
+
+ fun removeCustomPageView(view: View) {
+ customPageViews.remove(view)
+ (view.parent as? ViewGroup)?.removeView(view)
+ setupView()
+ }
+
+ fun customPageViewAt(index: Int): View? = customPageViews.getOrNull(index)
+
+ fun customPageCount(): Int = customPageViews.size
+
+ fun navigateCustomPage(action: String, routeKey: String?) {
+ when (action) {
+ "back" -> customNavigator?.navigateBack()
+ "popToRoot" -> customNavigator?.popToRoot()
+ "push" -> routeKey?.let { customNavigator?.push(it) }
+ }
+ }
+
+ @Composable
+ private fun CustomPageDestination(routeKey: String) {
+ customNavigator = LocalUserProfileCustomNavigator.current
+ val rows = customRows()
+ val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return
+
+ LaunchedEffect(routeKey) {
+ layoutAndroidViewHandler(view)
+ sendCustomPageEvent("presented", routeKey)
+ }
+ DisposableEffect(routeKey) {
+ onDispose { sendCustomPageEvent("dismissed", routeKey) }
+ }
+
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = {
+ (view.parent as? ViewGroup)?.removeView(view)
+ view
+ },
+ )
+ }
+
+ private fun customRows(): List {
+ return runCatching {
+ parseUserProfileCustomPages(customPagesJson, customPageViews.size)
+ }
+ .getOrElse {
+ debugLog(TAG, "Ignoring invalid custom rows: ${it.message}")
+ emptyList()
+ }
+ }
+
private fun sendEvent(type: String) {
onProfileEvent(mapOf("type" to type))
}
+
+ private fun sendCustomPageEvent(type: String, path: String) {
+ onCustomPageEvent(mapOf("type" to type, "path" to path))
+ }
}
class ClerkUserProfileViewModule : Module() {
@@ -75,7 +220,15 @@ class ClerkUserProfileViewModule : Module() {
Name("ClerkUserProfileView")
View(ClerkUserProfileNativeView::class) {
- Events("onProfileEvent", "onHostBack")
+ Events("onProfileEvent", "onCustomPageEvent", "onHostBack")
+
+ GroupView {
+ AddChildView { parent, child, index -> parent.addCustomPageView(child, index) }
+ GetChildCount { parent -> parent.customPageCount() }
+ GetChildViewAt { parent, index -> parent.customPageViewAt(index) }
+ RemoveChildView { parent, child -> parent.removeCustomPageView(child) }
+ RemoveChildViewAt { parent, index -> parent.customPageViewAt(index)?.let(parent::removeCustomPageView) }
+ }
Prop("isDismissible") { view: ClerkUserProfileNativeView, isDismissible: Boolean ->
view.isDismissible = isDismissible
@@ -85,6 +238,16 @@ class ClerkUserProfileViewModule : Module() {
view.hostBackButton = hostBackButton
}
+ Prop("customPages") { view: ClerkUserProfileNativeView, customPages: String ->
+ view.customPagesJson = customPages
+ }
+
+ AsyncFunction("navigateCustomPage") {
+ view: ClerkUserProfileNativeView,
+ action: String,
+ routeKey: String? -> view.navigateCustomPage(action, routeKey)
+ }
+
OnViewDidUpdateProps { view: ClerkUserProfileNativeView ->
view.setupView()
}
diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift
index fccd156a501..c159d70c343 100644
--- a/packages/expo/ios/ClerkNativeBridge.swift
+++ b/packages/expo/ios/ClerkNativeBridge.swift
@@ -42,6 +42,133 @@ final class ClerkInlineAuthLogoState {
}
}
+@MainActor
+@Observable
+final class ClerkUserProfileCustomPageState {
+ private(set) var views: [UIView] = []
+ @ObservationIgnored private var navigator: UserProfileNavigator?
+ @ObservationIgnored private var navigateBackAction: (() -> Void)?
+ @ObservationIgnored private var pageEventHandler: ((String, String) -> Void)?
+
+ func insertView(_ view: UIView, at index: Int) {
+ view.removeFromSuperview()
+ views.insert(view, at: min(max(index, 0), views.count))
+ }
+
+ func removeView(_ view: UIView) {
+ guard let index = views.firstIndex(where: { $0 === view }) else { return }
+ view.removeFromSuperview()
+ views.remove(at: index)
+ }
+
+ func configureNavigation(
+ _ navigator: UserProfileNavigator,
+ navigateBack: @escaping () -> Void
+ ) {
+ self.navigator = navigator
+ navigateBackAction = navigateBack
+ }
+
+ func setPageEventHandler(_ handler: @escaping (String, String) -> Void) {
+ pageEventHandler = handler
+ }
+
+ func sendPageEvent(type: String, path: String) {
+ pageEventHandler?(type, path)
+ }
+
+ func navigate(action: String, routeKey: String?) {
+ switch action {
+ case "back":
+ navigateBackAction?()
+ case "popToRoot":
+ navigator?.popToRoot()
+ case "push":
+ if let routeKey {
+ navigator?.push(routeKey)
+ }
+ default:
+ break
+ }
+ }
+}
+
+struct ClerkUserProfileCustomRowConfig: Decodable {
+ struct Placement: Decodable {
+ let type: String
+ let section: String?
+ let row: String?
+ }
+
+ let path: String
+ let label: String
+ let icon: String
+ let placement: Placement
+
+ var nativeRow: UserProfileCustomRow {
+ UserProfileCustomRow(
+ route: path,
+ title: label,
+ icon: .system(name: systemIconName),
+ placement: nativePlacement
+ )
+ }
+
+ private var systemIconName: String {
+ switch icon {
+ case "user": "person"
+ case "profile": "person.crop.circle"
+ case "security": "shield"
+ case "billing": "creditcard"
+ case "key": "key"
+ case "lock": "lock"
+ case "email": "envelope"
+ case "phone": "phone"
+ case "add": "plus"
+ case "switch": "arrow.left.arrow.right"
+ case "users": "person.2"
+ case "warning": "exclamationmark.triangle"
+ case "info": "info.circle"
+ case "globe": "globe"
+ case "folder": "folder"
+ case "book": "book"
+ default: "gearshape"
+ }
+ }
+
+ private var nativePlacement: UserProfileCustomRowPlacement {
+ switch placement.type {
+ case "sectionStart": .sectionStart(nativeSection)
+ case "before": .before(nativeAnchorRow)
+ case "after": .after(nativeAnchorRow)
+ default: .sectionEnd(nativeSection)
+ }
+ }
+
+ private var nativeSection: UserProfileSection {
+ placement.section == "account" ? .account : .profile
+ }
+
+ private var nativeAnchorRow: UserProfileRow {
+ switch placement.row {
+ case "security": .security
+ case "switchAccount": .switchAccount
+ case "addAccount": .addAccount
+ case "signOut": .signOut
+ default: .manageAccount
+ }
+ }
+}
+
+func parseUserProfileCustomPages(_ json: String, pageCount: Int) -> [ClerkUserProfileCustomRowConfig] {
+ guard let data = json.data(using: .utf8),
+ let rows = try? JSONDecoder().decode([ClerkUserProfileCustomRowConfig].self, from: data)
+ else {
+ return []
+ }
+ return Array(rows.prefix(pageCount))
+}
+
private let clerkNativeClientEventQueue = DispatchQueue(label: "com.clerk.expo.native-client-events")
private var clerkNativeClientChangedEmitter: (([String: Any]?) -> Void)?
@@ -292,6 +419,8 @@ final class ClerkNativeBridge {
func makeUserProfileViewController(
dismissible: Bool,
+ customRows: [ClerkUserProfileCustomRowConfig],
+ customPageState: ClerkUserProfileCustomPageState,
hostBackAction: (() -> Void)? = nil,
onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void
) -> UIViewController? {
@@ -302,19 +431,26 @@ final class ClerkNativeBridge {
dismissible: dismissible,
hostBackAction: hostBackAction.map(ClerkHostBackAction.init),
lightTheme: lightTheme,
- darkTheme: darkTheme
+ darkTheme: darkTheme,
+ customRows: customRows,
+ customPageState: customPageState
),
onDismiss: dismissible ? { onEvent(.dismissed, [:]) } : nil
)
}
- func makeUserButtonViewController() -> UIViewController? {
+ func makeUserButtonViewController(
+ customRows: [ClerkUserProfileCustomRowConfig],
+ customPageState: ClerkUserProfileCustomPageState
+ ) -> UIViewController? {
guard Self.clerkConfigured else { return nil }
return makeHostingController(
rootView: ClerkInlineUserButtonWrapperView(
lightTheme: lightTheme,
- darkTheme: darkTheme
+ darkTheme: darkTheme,
+ customRows: customRows,
+ customPageState: customPageState
)
)
}
@@ -511,11 +647,21 @@ final class ClerkNativeBridge {
struct ClerkInlineUserButtonWrapperView: View {
let lightTheme: ClerkTheme?
let darkTheme: ClerkTheme?
+ let customRows: [ClerkUserProfileCustomRowConfig]
+ let customPageState: ClerkUserProfileCustomPageState
@Environment(\.colorScheme) private var colorScheme
var body: some View {
let view = UserButton()
+ .userProfileRows(customRows.map(\.nativeRow))
+ .userProfileDestination { routeKey in
+ ClerkReactUserProfileCustomPage(
+ path: routeKey,
+ rows: customRows,
+ state: customPageState
+ )
+ }
.environment(Clerk.shared)
let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
let themedView = Group {
@@ -576,16 +722,16 @@ struct ClerkInlineAuthWrapperView: View {
private struct ClerkReactLogoView: UIViewRepresentable {
let view: UIView
- func makeUIView(context: Context) -> ClerkReactLogoContainerView {
- return ClerkReactLogoContainerView(contentView: view)
+ func makeUIView(context: Context) -> ClerkReactContentContainerView {
+ return ClerkReactContentContainerView(contentView: view)
}
- func updateUIView(_ uiView: ClerkReactLogoContainerView, context: Context) {
+ func updateUIView(_ uiView: ClerkReactContentContainerView, context: Context) {
uiView.setContentView(view)
}
}
-private final class ClerkReactLogoContainerView: UIView {
+private final class ClerkReactContentContainerView: UIView {
private var contentView: UIView?
init(contentView: UIView) {
@@ -644,11 +790,21 @@ struct ClerkInlineProfileWrapperView: View {
let hostBackAction: ClerkHostBackAction?
let lightTheme: ClerkTheme?
let darkTheme: ClerkTheme?
+ let customRows: [ClerkUserProfileCustomRowConfig]
+ let customPageState: ClerkUserProfileCustomPageState
@Environment(\.colorScheme) private var colorScheme
var body: some View {
let view = UserProfileView(isDismissible: dismissible)
+ .userProfileRows(customRows.map(\.nativeRow))
+ .userProfileDestination { routeKey in
+ ClerkReactUserProfileCustomPage(
+ path: routeKey,
+ rows: customRows,
+ state: customPageState
+ )
+ }
.environment(Clerk.shared)
.environment(\.clerkHostBackAction, hostBackAction)
let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
@@ -662,3 +818,44 @@ struct ClerkInlineProfileWrapperView: View {
themedView
}
}
+
+private struct ClerkReactUserProfileCustomPage: View {
+ @Environment(UserProfileNavigator.self) private var navigator
+ @Environment(\.dismiss) private var dismiss
+
+ let path: String
+ let rows: [ClerkUserProfileCustomRowConfig]
+ let state: ClerkUserProfileCustomPageState
+
+ var body: some View {
+ Group {
+ if let index = rows.firstIndex(where: { $0.path == path }),
+ state.views.indices.contains(index)
+ {
+ ClerkReactCustomPageView(view: state.views[index])
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .onAppear {
+ state.configureNavigation(navigator) {
+ dismiss()
+ }
+ state.sendPageEvent(type: "presented", path: path)
+ }
+ .onDisappear {
+ state.sendPageEvent(type: "dismissed", path: path)
+ }
+ }
+}
+
+private struct ClerkReactCustomPageView: UIViewRepresentable {
+ let view: UIView
+
+ func makeUIView(context: Context) -> ClerkReactContentContainerView {
+ ClerkReactContentContainerView(contentView: view)
+ }
+
+ func updateUIView(_ uiView: ClerkReactContentContainerView, context: Context) {
+ uiView.setContentView(view)
+ }
+}
diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo/ios/ClerkNativeViewHost.swift
index 0d91f0e749f..75347de32f8 100644
--- a/packages/expo/ios/ClerkNativeViewHost.swift
+++ b/packages/expo/ios/ClerkNativeViewHost.swift
@@ -82,6 +82,63 @@ public class ClerkNativeViewHost: ExpoView {
}
}
+public class ClerkUserProfileCustomPageHost: ClerkNativeViewHost {
+ private var currentCustomPages: String = "[]"
+ let customPageState = ClerkUserProfileCustomPageState()
+
+ // Expo resolves event dispatchers through `Mirror(reflecting:).children`, which skips
+ // inherited properties, so subclasses must own the dispatcher and surface it here.
+ var customPageEventDispatcher: EventDispatcher? { nil }
+
+ public required init(appContext: AppContext? = nil) {
+ super.init(appContext: appContext)
+ customPageState.setPageEventHandler { [weak self] type, path in
+ self?.customPageEventDispatcher?(["type": type, "path": path])
+ }
+ }
+
+ func setCustomPages(_ customPages: String?) {
+ let newCustomPages = customPages ?? "[]"
+ guard newCustomPages != currentCustomPages else { return }
+ currentCustomPages = newCustomPages
+ setNeedsHostedViewUpdate()
+ }
+
+ func navigateCustomPage(action: String, routeKey: String?) {
+ customPageState.navigate(action: action, routeKey: routeKey)
+ }
+
+ func customRows() -> [ClerkUserProfileCustomRowConfig] {
+ parseUserProfileCustomPages(currentCustomPages, pageCount: customPageState.views.count)
+ }
+
+#if RCT_NEW_ARCH_ENABLED
+ override public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
+ customPageState.insertView(childComponentView, at: index)
+ setNeedsHostedViewUpdate()
+ }
+
+ override public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
+ customPageState.removeView(childComponentView)
+ setNeedsHostedViewUpdate()
+ }
+#else
+ override public func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
+ super.insertReactSubview(subview, at: atIndex)
+ customPageState.insertView(subview, at: atIndex)
+ setNeedsHostedViewUpdate()
+ }
+
+ override public func removeReactSubview(_ subview: UIView!) {
+ customPageState.removeView(subview)
+ super.removeReactSubview(subview)
+ setNeedsHostedViewUpdate()
+ }
+
+ override public func didUpdateReactSubviews() {}
+#endif
+}
+
private final class ClerkNativeHostingCoordinator {
private weak var containerView: UIView?
private var hostingController: UIViewController?
diff --git a/packages/expo/ios/ClerkUserButtonNativeView.swift b/packages/expo/ios/ClerkUserButtonNativeView.swift
index c32c7c8668c..7a02633f6d8 100644
--- a/packages/expo/ios/ClerkUserButtonNativeView.swift
+++ b/packages/expo/ios/ClerkUserButtonNativeView.swift
@@ -1,9 +1,16 @@
import ExpoModulesCore
import UIKit
-public class ClerkUserButtonNativeView: ClerkNativeViewHost {
+public class ClerkUserButtonNativeView: ClerkUserProfileCustomPageHost {
+ let onCustomPageEvent = EventDispatcher()
+
+ override var customPageEventDispatcher: EventDispatcher? { onCustomPageEvent }
+
override func makeHostedController() -> UIViewController? {
- return ClerkNativeBridge.shared.makeUserButtonViewController()
+ return ClerkNativeBridge.shared.makeUserButtonViewController(
+ customRows: customRows(),
+ customPageState: customPageState
+ )
}
}
@@ -11,6 +18,17 @@ public class ClerkUserButtonViewModule: Module {
public func definition() -> ModuleDefinition {
Name("ClerkUserButtonView")
- View(ClerkUserButtonNativeView.self) {}
+ View(ClerkUserButtonNativeView.self) {
+ Events("onCustomPageEvent")
+
+ Prop("customPages") { (view: ClerkUserButtonNativeView, customPages: String?) in
+ view.setCustomPages(customPages)
+ }
+
+ AsyncFunction("navigateCustomPage") {
+ (view: ClerkUserButtonNativeView, action: String, routeKey: String?) in
+ view.navigateCustomPage(action: action, routeKey: routeKey)
+ }
+ }
}
}
diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo/ios/ClerkUserProfileNativeView.swift
index 12d6248b1dc..f2ca604d41f 100644
--- a/packages/expo/ios/ClerkUserProfileNativeView.swift
+++ b/packages/expo/ios/ClerkUserProfileNativeView.swift
@@ -1,13 +1,16 @@
import ExpoModulesCore
import UIKit
-public class ClerkUserProfileNativeView: ClerkNativeViewHost {
+public class ClerkUserProfileNativeView: ClerkUserProfileCustomPageHost {
private var currentDismissible: Bool = true
private var currentHostBackButton: Bool = false
private var didSendDismiss = false
let onProfileEvent = EventDispatcher()
let onHostBack = EventDispatcher()
+ let onCustomPageEvent = EventDispatcher()
+
+ override var customPageEventDispatcher: EventDispatcher? { onCustomPageEvent }
func setDismissible(_ isDismissible: Bool?) {
let newDismissible = isDismissible ?? true
@@ -49,6 +52,8 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost {
return ClerkNativeBridge.shared.makeUserProfileViewController(
dismissible: currentDismissible,
+ customRows: customRows(),
+ customPageState: customPageState,
hostBackAction: hostBackAction,
onEvent: { [weak self] event, _ in
if event == .dismissed {
@@ -64,7 +69,7 @@ public class ClerkUserProfileViewModule: Module {
Name("ClerkUserProfileView")
View(ClerkUserProfileNativeView.self) {
- Events("onProfileEvent", "onHostBack")
+ Events("onProfileEvent", "onCustomPageEvent", "onHostBack")
Prop("isDismissible") { (view: ClerkUserProfileNativeView, isDismissible: Bool?) in
view.setDismissible(isDismissible)
@@ -73,6 +78,15 @@ public class ClerkUserProfileViewModule: Module {
Prop("hostBackButton") { (view: ClerkUserProfileNativeView, hostBackButton: Bool?) in
view.setHostBackButton(hostBackButton)
}
+
+ Prop("customPages") { (view: ClerkUserProfileNativeView, customPages: String?) in
+ view.setCustomPages(customPages)
+ }
+
+ AsyncFunction("navigateCustomPage") {
+ (view: ClerkUserProfileNativeView, action: String, routeKey: String?) in
+ view.navigateCustomPage(action: action, routeKey: routeKey)
+ }
}
}
}
diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx
index 96bf4e37da6..f5ed2bc8b32 100644
--- a/packages/expo/src/native/UserButton.tsx
+++ b/packages/expo/src/native/UserButton.tsx
@@ -1,7 +1,39 @@
-import { StyleSheet } from 'react-native';
+import type { ComponentProps, ComponentType, JSX, Ref } from 'react';
+import { useRef } from 'react';
+import type { NativeSyntheticEvent } from 'react-native';
+import { StyleSheet, useWindowDimensions } from 'react-native';
import NativeClerkUserButtonView from '../specs/NativeClerkUserButtonView';
import { isNativeSupported } from '../utils/native-module';
+import type {
+ NativeUserProfileNavigationHandle,
+ UserProfileCustomPage,
+ UserProfileCustomPageEvent,
+} from './UserProfileCustomPages';
+import {
+ serializeUserProfileCustomPages,
+ UserProfileCustomPageHosts,
+ useUserProfileCustomPages,
+} from './UserProfileCustomPages';
+
+type CustomizableNativeUserButtonProps = ComponentProps> & {
+ customPages?: string;
+ onCustomPageEvent?: (event: NativeSyntheticEvent) => void;
+ ref?: Ref;
+};
+
+const CustomizableNativeClerkUserButtonView =
+ NativeClerkUserButtonView as ComponentType | null;
+
+export interface UserButtonUserProfileProps {
+ /** Custom pages displayed as rows in the user profile. */
+ customPages?: UserProfileCustomPage[];
+}
+
+export interface UserButtonProps {
+ /** Configuration passed to the user profile opened by the button. */
+ userProfileProps?: UserButtonUserProfileProps;
+}
/**
* A pre-built button component that displays the user's avatar.
@@ -14,19 +46,44 @@ import { isNativeSupported } from '../utils/native-module';
* import { UserButton } from '@clerk/expo/native';
*
* export default function Home() {
- * return ;
+ * return (
+ * }],
+ * }}
+ * />
+ * );
* }
* ```
*
* @see {@link UserProfileView} The profile view to render in your own presentation surface
* @see {@link https://clerk.com/docs/components/user/user-button} Clerk UserButton Documentation
*/
-export function UserButton() {
- if (!isNativeSupported || !NativeClerkUserButtonView) {
+export function UserButton({ userProfileProps }: UserButtonProps): JSX.Element | null {
+ const nativeViewRef = useRef(null);
+ const customPages = userProfileProps?.customPages ?? [];
+ const { activePath, onCustomPageEvent } = useUserProfileCustomPages(customPages, nativeViewRef);
+ const { width, height } = useWindowDimensions();
+
+ if (!isNativeSupported || !CustomizableNativeClerkUserButtonView) {
return null;
}
- return ;
+ return (
+
+
+
+ );
}
const styles = StyleSheet.create({
diff --git a/packages/expo/src/native/UserProfileCustomPages.tsx b/packages/expo/src/native/UserProfileCustomPages.tsx
new file mode 100644
index 00000000000..a990971cc80
--- /dev/null
+++ b/packages/expo/src/native/UserProfileCustomPages.tsx
@@ -0,0 +1,206 @@
+import type { ReactNode, RefObject } from 'react';
+import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
+import type { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native';
+import { Linking, StyleSheet, View } from 'react-native';
+
+export type UserProfileCustomPageIcon =
+ | 'user'
+ | 'profile'
+ | 'security'
+ | 'settings'
+ | 'billing'
+ | 'key'
+ | 'lock'
+ | 'email'
+ | 'phone'
+ | 'add'
+ | 'switch'
+ | 'users'
+ | 'warning'
+ | 'info'
+ | 'globe'
+ | 'folder'
+ | 'book';
+
+export type UserProfileSection = 'profile' | 'account';
+
+export type UserProfileRow = 'manageAccount' | 'security' | 'switchAccount' | 'addAccount' | 'signOut';
+
+export type UserProfileCustomPagePlacement =
+ | { type: 'sectionStart'; section: UserProfileSection }
+ | { type: 'sectionEnd'; section: UserProfileSection }
+ | { type: 'before'; row: UserProfileRow }
+ | { type: 'after'; row: UserProfileRow };
+
+interface UserProfileCustomPageBase {
+ /** Unique path used to identify and navigate to the page. */
+ path: string;
+
+ /** Text displayed in the native user profile row. */
+ label: string;
+
+ /** Icon displayed in the native user profile row. */
+ icon?: UserProfileCustomPageIcon;
+
+ /** Where the row is inserted relative to Clerk's built-in rows. */
+ placement?: UserProfileCustomPagePlacement;
+}
+
+/** A custom page rendered from the root of the native user profile. */
+export type UserProfileCustomPage = UserProfileCustomPageBase &
+ (
+ | {
+ /** React Native content rendered when the row is selected. */
+ content: ReactNode;
+ href?: never;
+ }
+ | {
+ /** URL opened when the row is selected. */
+ href: string;
+ content?: never;
+ }
+ );
+
+export type UserProfileCustomPageEvent = Readonly<{
+ type: 'presented' | 'dismissed';
+ path: string;
+}>;
+
+/** Navigation available to content rendered by a custom user profile page. */
+export interface UserProfileCustomPageNavigation {
+ /** Navigates back one screen in the native user profile. */
+ navigateBack: () => Promise;
+
+ /** Returns to the root user profile screen. */
+ popToRoot: () => Promise;
+
+ /** Pushes another custom page by path. */
+ push: (path: string) => Promise;
+}
+
+export interface NativeUserProfileNavigationHandle {
+ navigateCustomPage: (action: 'back' | 'popToRoot' | 'push', path?: string) => Promise;
+}
+
+const UserProfileCustomPageNavigationContext = createContext(null);
+
+/** Returns navigation actions for the currently rendered custom user profile destination. */
+export function useUserProfileCustomPageNavigation(): UserProfileCustomPageNavigation {
+ const navigation = useContext(UserProfileCustomPageNavigationContext);
+
+ if (!navigation) {
+ throw new Error('useUserProfileCustomPageNavigation must be used inside a custom user profile page.');
+ }
+
+ return navigation;
+}
+
+export function serializeUserProfileCustomPages(customPages: UserProfileCustomPage[]): string {
+ const paths = new Set();
+
+ for (const { path } of customPages) {
+ if (paths.has(path)) {
+ throw new Error(`User profile custom page path "${path}" must be unique.`);
+ }
+
+ paths.add(path);
+ }
+
+ return JSON.stringify(
+ customPages.map(page => ({
+ path: page.path,
+ label: page.label,
+ icon: page.icon ?? 'settings',
+ placement: page.placement ?? { type: 'sectionEnd', section: 'profile' },
+ })),
+ );
+}
+
+export function useUserProfileCustomPages(
+ customPages: UserProfileCustomPage[],
+ navigationHandleRef: RefObject,
+) {
+ const [activePath, setActivePath] = useState();
+ const openingPaths = useRef(new Set());
+
+ const onCustomPageEvent = useCallback(
+ (event: NativeSyntheticEvent) => {
+ const { path, type } = event.nativeEvent;
+
+ if (type === 'dismissed') {
+ setActivePath(currentPath => (currentPath === path ? undefined : currentPath));
+ return;
+ }
+
+ const page = customPages.find(candidate => candidate.path === path);
+ if (!page) {
+ return;
+ }
+
+ if ('href' in page && page.href) {
+ if (openingPaths.current.has(path)) {
+ return;
+ }
+
+ openingPaths.current.add(path);
+ void Linking.openURL(page.href)
+ .catch(error => {
+ console.warn(`Could not open custom user profile page "${path}".`, error);
+ })
+ .finally(() => {
+ openingPaths.current.delete(path);
+ void navigationHandleRef.current?.navigateCustomPage('back');
+ });
+ return;
+ }
+
+ setActivePath(path);
+ },
+ [customPages, navigationHandleRef],
+ );
+
+ return { activePath, onCustomPageEvent };
+}
+
+export function UserProfileCustomPageHosts({
+ customPages,
+ activePath,
+ navigationHandleRef,
+ style,
+}: {
+ customPages: UserProfileCustomPage[];
+ activePath?: string;
+ navigationHandleRef: RefObject;
+ style?: StyleProp;
+}) {
+ const navigation = useMemo(
+ () => ({
+ navigateBack: () => navigationHandleRef.current?.navigateCustomPage('back') ?? Promise.resolve(),
+ popToRoot: () => navigationHandleRef.current?.navigateCustomPage('popToRoot') ?? Promise.resolve(),
+ push: path => navigationHandleRef.current?.navigateCustomPage('push', path) ?? Promise.resolve(),
+ }),
+ [navigationHandleRef],
+ );
+
+ return customPages.map(page => (
+
+
+ {activePath === page.path && 'content' in page ? page.content : null}
+
+
+ ));
+}
+
+const styles = StyleSheet.create({
+ destination: {
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ },
+});
diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo/src/native/UserProfileView.tsx
index e1eba05cf6d..dff4e86d9e6 100644
--- a/packages/expo/src/native/UserProfileView.tsx
+++ b/packages/expo/src/native/UserProfileView.tsx
@@ -1,10 +1,30 @@
-import { useCallback } from 'react';
-import type { StyleProp, ViewStyle } from 'react-native';
+import type { ComponentProps, ComponentType, JSX, Ref } from 'react';
+import { useCallback, useRef } from 'react';
+import type { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native';
import { StyleSheet, Text, View } from 'react-native';
import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView';
import { isNativeSupported } from '../utils/native-module';
import type { EmbeddedNavigationProps } from './EmbeddedNavigation.types';
+import type {
+ NativeUserProfileNavigationHandle,
+ UserProfileCustomPage,
+ UserProfileCustomPageEvent,
+} from './UserProfileCustomPages';
+import {
+ serializeUserProfileCustomPages,
+ UserProfileCustomPageHosts,
+ useUserProfileCustomPages,
+} from './UserProfileCustomPages';
+
+type CustomizableNativeUserProfileProps = ComponentProps> & {
+ customPages?: string;
+ onCustomPageEvent?: (event: NativeSyntheticEvent) => void;
+ ref?: Ref;
+};
+
+const CustomizableNativeClerkUserProfileView =
+ NativeClerkUserProfileView as ComponentType | null;
/**
* Props for the UserProfileView component.
@@ -29,6 +49,9 @@ export interface UserProfileViewProps extends EmbeddedNavigationProps {
* Called when the user dismisses the native profile view.
*/
onDismiss?: () => void;
+
+ /** Custom pages displayed as rows in the root profile screen. */
+ customPages?: UserProfileCustomPage[];
}
/**
@@ -58,13 +81,29 @@ export interface UserProfileViewProps extends EmbeddedNavigationProps {
* if (!isSignedIn) router.replace('/sign-in');
* }, [isSignedIn]);
*
- * return ;
+ * return (
+ * },
+ * { path: 'docs', label: 'Docs', icon: 'book', href: 'https://clerk.com/docs' },
+ * ]}
+ * />
+ * );
* }
* ```
*
* @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation
*/
-export function UserProfileView({ isDismissible = true, style, onDismiss, onHostBack }: UserProfileViewProps) {
+export function UserProfileView({
+ isDismissible = true,
+ style,
+ onDismiss,
+ onHostBack,
+ customPages = [],
+}: UserProfileViewProps): JSX.Element {
+ const nativeViewRef = useRef(null);
+ const { activePath, onCustomPageEvent } = useUserProfileCustomPages(customPages, nativeViewRef);
const handleProfileEvent = useCallback(
(event: { nativeEvent: { type: string } }) => {
if (event.nativeEvent.type === 'dismissed') {
@@ -74,7 +113,7 @@ export function UserProfileView({ isDismissible = true, style, onDismiss, onHost
[onDismiss],
);
- if (!isNativeSupported || !NativeClerkUserProfileView) {
+ if (!isNativeSupported || !CustomizableNativeClerkUserProfileView) {
return (
@@ -87,13 +126,22 @@ export function UserProfileView({ isDismissible = true, style, onDismiss, onHost
}
return (
- onHostBack() : undefined}
- />
+ >
+
+
);
}
diff --git a/packages/expo/src/native/__tests__/UserButton.test.tsx b/packages/expo/src/native/__tests__/UserButton.test.tsx
new file mode 100644
index 00000000000..85822876a88
--- /dev/null
+++ b/packages/expo/src/native/__tests__/UserButton.test.tsx
@@ -0,0 +1,56 @@
+import { render } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, test, vi } from 'vitest';
+
+import { UserButton } from '../UserButton';
+
+const mocks = vi.hoisted(() => ({
+ nativeProps: vi.fn(),
+}));
+
+vi.mock('../../specs/NativeClerkUserButtonView', () => ({
+ default: React.forwardRef((props: Record, _ref) => {
+ mocks.nativeProps(props);
+ return null;
+ }),
+}));
+
+vi.mock('../../utils/native-module', () => ({
+ isNativeSupported: true,
+}));
+
+vi.mock('react-native', () => ({
+ Linking: { openURL: vi.fn() },
+ StyleSheet: { create: (styles: T) => styles },
+ View: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children),
+ useWindowDimensions: () => ({ width: 390, height: 844 }),
+}));
+
+describe('UserButton', () => {
+ test('passes nested user profile custom pages to the native view', () => {
+ render(
+ API keys page,
+ },
+ ],
+ }}
+ />,
+ );
+
+ const props = mocks.nativeProps.mock.calls.at(-1)?.[0];
+ expect(JSON.parse(props.customPages)).toEqual([
+ {
+ path: 'api-keys',
+ label: 'API keys',
+ icon: 'key',
+ placement: { type: 'sectionEnd', section: 'profile' },
+ },
+ ]);
+ });
+});
diff --git a/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx b/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx
new file mode 100644
index 00000000000..c34c01eed86
--- /dev/null
+++ b/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx
@@ -0,0 +1,61 @@
+import { describe, expect, test, vi } from 'vitest';
+
+vi.mock('react-native', () => ({
+ Linking: { openURL: vi.fn() },
+ StyleSheet: { create: (styles: T) => styles },
+ View: 'View',
+}));
+
+import { serializeUserProfileCustomPages } from '../UserProfileCustomPages';
+
+describe('serializeUserProfileCustomPages', () => {
+ test('uses native defaults and excludes React content', () => {
+ const result = serializeUserProfileCustomPages([
+ {
+ path: 'preferences',
+ label: 'Preferences',
+ content: null,
+ },
+ ]);
+
+ expect(JSON.parse(result)).toEqual([
+ {
+ path: 'preferences',
+ label: 'Preferences',
+ icon: 'settings',
+ placement: { type: 'sectionEnd', section: 'profile' },
+ },
+ ]);
+ });
+
+ test('preserves row declaration order and placement', () => {
+ const result = serializeUserProfileCustomPages([
+ {
+ path: 'support',
+ label: 'Support',
+ icon: 'info',
+ placement: { type: 'sectionStart', section: 'account' },
+ content: null,
+ },
+ {
+ path: 'billing',
+ label: 'Billing',
+ icon: 'billing',
+ placement: { type: 'before', row: 'signOut' },
+ content: null,
+ },
+ ]);
+
+ expect(JSON.parse(result).map((page: { path: string }) => page.path)).toEqual(['support', 'billing']);
+ expect(JSON.parse(result)[1].placement).toEqual({ type: 'before', row: 'signOut' });
+ });
+
+ test('rejects duplicate page paths', () => {
+ expect(() =>
+ serializeUserProfileCustomPages([
+ { path: 'billing', label: 'Billing', content: null },
+ { path: 'billing', label: 'Invoices', content: null },
+ ]),
+ ).toThrow('User profile custom page path "billing" must be unique.');
+ });
+});
diff --git a/packages/expo/src/native/__tests__/UserProfileView.test.tsx b/packages/expo/src/native/__tests__/UserProfileView.test.tsx
index 099e3308fbc..8e7bd6a5f1f 100644
--- a/packages/expo/src/native/__tests__/UserProfileView.test.tsx
+++ b/packages/expo/src/native/__tests__/UserProfileView.test.tsx
@@ -1,21 +1,24 @@
-import { render } from '@testing-library/react';
+import { act, render, waitFor } from '@testing-library/react';
import React from 'react';
-import { describe, expect, test, vi } from 'vitest';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
import { UserProfileView } from '../UserProfileView';
const mocks = vi.hoisted(() => {
return {
+ navigateCustomPage: vi.fn(() => Promise.resolve()),
nativeProps: vi.fn(),
+ openURL: vi.fn(() => Promise.resolve()),
};
});
vi.mock('../../specs/NativeClerkUserProfileView', () => {
return {
- default: (props: Record) => {
+ default: React.forwardRef((props: { children?: React.ReactNode }, ref) => {
+ React.useImperativeHandle(ref, () => ({ navigateCustomPage: mocks.navigateCustomPage }));
mocks.nativeProps(props);
- return null;
- },
+ return <>{props.children}>;
+ }),
};
});
@@ -27,6 +30,7 @@ vi.mock('../../utils/native-module', () => {
vi.mock('react-native', () => {
return {
+ Linking: { openURL: mocks.openURL },
Text: ({ children }: { children?: React.ReactNode }) => React.createElement('span', null, children),
View: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children),
StyleSheet: { create: (styles: T) => styles },
@@ -38,6 +42,12 @@ function lastNativeProps() {
}
describe('UserProfileView', () => {
+ beforeEach(() => {
+ mocks.navigateCustomPage.mockClear();
+ mocks.nativeProps.mockClear();
+ mocks.openURL.mockClear();
+ });
+
test('calls onDismiss when the native profile view emits dismissed', () => {
const onDismiss = vi.fn();
@@ -67,4 +77,79 @@ describe('UserProfileView', () => {
expect(props.hostBackButton).toBe(false);
expect(props.onHostBack).toBeUndefined();
});
+
+ test('serializes custom pages for the native profile view', () => {
+ render(
+ Billing page,
+ },
+ ]}
+ />,
+ );
+
+ expect(JSON.parse(lastNativeProps().customPages)).toEqual([
+ {
+ path: 'billing',
+ label: 'Billing',
+ icon: 'billing',
+ placement: { type: 'after', row: 'security' },
+ },
+ ]);
+ });
+
+ test('mounts custom page content only while its native page is presented', () => {
+ const result = render(
+ API keys page }]} />,
+ );
+
+ expect(result.queryByText('API keys page')).toBeNull();
+
+ act(() => {
+ lastNativeProps().onCustomPageEvent({ nativeEvent: { type: 'presented', path: 'api-keys' } });
+ });
+ expect(result.getByText('API keys page')).toBeDefined();
+
+ act(() => {
+ lastNativeProps().onCustomPageEvent({ nativeEvent: { type: 'dismissed', path: 'api-keys' } });
+ });
+ expect(result.queryByText('API keys page')).toBeNull();
+ });
+
+ test('opens href pages externally and returns to the profile root', async () => {
+ render();
+
+ act(() => {
+ lastNativeProps().onCustomPageEvent({ nativeEvent: { type: 'presented', path: 'docs' } });
+ });
+
+ await waitFor(() => {
+ expect(mocks.openURL).toHaveBeenCalledWith('https://clerk.com/docs');
+ expect(mocks.navigateCustomPage).toHaveBeenCalledWith('back');
+ });
+ });
+
+ test('warns when an href page cannot be opened and returns to the profile root', async () => {
+ const error = new Error('Unable to open URL');
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ mocks.openURL.mockRejectedValueOnce(error);
+
+ render();
+
+ act(() => {
+ lastNativeProps().onCustomPageEvent({ nativeEvent: { type: 'presented', path: 'docs' } });
+ });
+
+ await waitFor(() => {
+ expect(warn).toHaveBeenCalledWith('Could not open custom user profile page "docs".', error);
+ expect(mocks.navigateCustomPage).toHaveBeenCalledWith('back');
+ });
+
+ warn.mockRestore();
+ });
});
diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts
index d892fb9a851..d19d5c8b298 100644
--- a/packages/expo/src/native/index.ts
+++ b/packages/expo/src/native/index.ts
@@ -32,5 +32,15 @@ export { AuthView } from './AuthView';
export type { AuthViewProps, AuthViewMode } from './AuthView.types';
export type { EmbeddedNavigationProps } from './EmbeddedNavigation.types';
export { UserButton } from './UserButton';
+export type { UserButtonProps, UserButtonUserProfileProps } from './UserButton';
+export { useUserProfileCustomPageNavigation } from './UserProfileCustomPages';
+export type {
+ UserProfileCustomPageNavigation,
+ UserProfileCustomPage,
+ UserProfileCustomPageIcon,
+ UserProfileCustomPagePlacement,
+ UserProfileRow,
+ UserProfileSection,
+} from './UserProfileCustomPages';
export { UserProfileView } from './UserProfileView';
export type { UserProfileViewProps } from './UserProfileView';