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';