From 999263cfa2209ad8c5866562506a5c0c2854b02c Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 13 Aug 2026 16:33:29 -0700 Subject: [PATCH 01/10] feat(expo): add custom native profile pages --- .changeset/expo-native-custom-pages.md | 5 + .../clerk/ClerkUserButtonViewModule.kt | 99 ++++++++- .../clerk/ClerkUserProfileViewModule.kt | 162 +++++++++++++- packages/expo/ios/ClerkNativeBridge.swift | 203 +++++++++++++++++- .../expo/ios/ClerkUserButtonNativeView.swift | 63 +++++- .../expo/ios/ClerkUserProfileNativeView.swift | 57 ++++- packages/expo/src/native/UserButton.tsx | 67 +++++- .../src/native/UserProfileCustomPages.tsx | 194 +++++++++++++++++ packages/expo/src/native/UserProfileView.tsx | 62 +++++- .../src/native/__tests__/UserButton.test.tsx | 56 +++++ .../__tests__/UserProfileCustomPages.test.tsx | 52 +++++ .../native/__tests__/UserProfileView.test.tsx | 76 ++++++- packages/expo/src/native/index.ts | 10 + 13 files changed, 1080 insertions(+), 26 deletions(-) create mode 100644 .changeset/expo-native-custom-pages.md create mode 100644 packages/expo/src/native/UserProfileCustomPages.tsx create mode 100644 packages/expo/src/native/__tests__/UserButton.test.tsx create mode 100644 packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx diff --git a/.changeset/expo-native-custom-pages.md b/.changeset/expo-native-custom-pages.md new file mode 100644 index 00000000000..4159edd8097 --- /dev/null +++ b/.changeset/expo-native-custom-pages.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': minor +--- + +Add custom user profile pages to the native `UserProfileView` and `UserButton` components. 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..1aa50e7b74c 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,15 +37,96 @@ 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) { 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)) + } } 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..be2dc881bd0 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,67 @@ 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) { 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 +217,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 +235,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..42ec5d09053 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 { @@ -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) -> ClerkReactLogoContainerView { + ClerkReactLogoContainerView(contentView: view) + } + + func updateUIView(_ uiView: ClerkReactLogoContainerView, context: Context) { + uiView.setContentView(view) + } +} diff --git a/packages/expo/ios/ClerkUserButtonNativeView.swift b/packages/expo/ios/ClerkUserButtonNativeView.swift index c32c7c8668c..c4e3e09d139 100644 --- a/packages/expo/ios/ClerkUserButtonNativeView.swift +++ b/packages/expo/ios/ClerkUserButtonNativeView.swift @@ -2,8 +2,56 @@ import ExpoModulesCore import UIKit public class ClerkUserButtonNativeView: ClerkNativeViewHost { + private var currentCustomPages: String = "[]" + private let customPageState = ClerkUserProfileCustomPageState() + let onCustomPageEvent = EventDispatcher() + + 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) + } + +#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 + override func makeHostedController() -> UIViewController? { - return ClerkNativeBridge.shared.makeUserButtonViewController() + customPageState.setPageEventHandler { [weak self] type, path in + self?.onCustomPageEvent(["type": type, "path": path]) + } + + return ClerkNativeBridge.shared.makeUserButtonViewController( + customRows: parseUserProfileCustomPages(currentCustomPages, pageCount: customPageState.views.count), + customPageState: customPageState + ) } } @@ -11,6 +59,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..82e354f44ab 100644 --- a/packages/expo/ios/ClerkUserProfileNativeView.swift +++ b/packages/expo/ios/ClerkUserProfileNativeView.swift @@ -4,9 +4,12 @@ import UIKit public class ClerkUserProfileNativeView: ClerkNativeViewHost { private var currentDismissible: Bool = true private var currentHostBackButton: Bool = false + private var currentCustomPages: String = "[]" + private let customPageState = ClerkUserProfileCustomPageState() private var didSendDismiss = false let onProfileEvent = EventDispatcher() + let onCustomPageEvent = EventDispatcher() let onHostBack = EventDispatcher() func setDismissible(_ isDismissible: Bool?) { @@ -23,6 +26,43 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost { setNeedsHostedViewUpdate() } + 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) + } + +#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 func sendProfileEvent(type: ClerkNativeViewEvent) { onProfileEvent(["type": type.rawValue]) } @@ -43,12 +83,18 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost { } override func makeHostedController() -> UIViewController? { + customPageState.setPageEventHandler { [weak self] type, path in + self?.onCustomPageEvent(["type": type, "path": path]) + } + let hostBackAction: (() -> Void)? = currentHostBackButton ? { [weak self] in self?.onHostBack([:]) } : nil return ClerkNativeBridge.shared.makeUserProfileViewController( dismissible: currentDismissible, + customRows: parseUserProfileCustomPages(currentCustomPages, pageCount: customPageState.views.count), + customPageState: customPageState, hostBackAction: hostBackAction, onEvent: { [weak self] event, _ in if event == .dismissed { @@ -64,7 +110,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 +119,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..5c057c3f99a 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, 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) { + 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..5c62cff7dcc --- /dev/null +++ b/packages/expo/src/native/UserProfileCustomPages.tsx @@ -0,0 +1,194 @@ +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 { + 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(() => undefined) + .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..6c3a4d65acd 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, 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) { + 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..89cbb67ebac --- /dev/null +++ b/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx @@ -0,0 +1,52 @@ +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' }); + }); +}); diff --git a/packages/expo/src/native/__tests__/UserProfileView.test.tsx b/packages/expo/src/native/__tests__/UserProfileView.test.tsx index 099e3308fbc..d6139f32706 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,60 @@ 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'); + }); + }); }); 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'; From 35af67115ca8f01e27acaa8c55b9814f852f20ae Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 13 Aug 2026 18:02:26 -0700 Subject: [PATCH 02/10] fix(expo): address custom profile page feedback --- .changeset/expo-native-custom-pages.md | 26 ++++++++- packages/expo/ios/ClerkNativeBridge.swift | 14 ++--- packages/expo/ios/ClerkNativeViewHost.swift | 54 +++++++++++++++++++ .../expo/ios/ClerkUserButtonNativeView.swift | 49 +---------------- .../expo/ios/ClerkUserProfileNativeView.swift | 48 +---------------- packages/expo/src/native/UserButton.tsx | 4 +- .../src/native/UserProfileCustomPages.tsx | 14 ++++- packages/expo/src/native/UserProfileView.tsx | 4 +- .../__tests__/UserProfileCustomPages.test.tsx | 9 ++++ .../native/__tests__/UserProfileView.test.tsx | 19 +++++++ 10 files changed, 135 insertions(+), 106 deletions(-) diff --git a/.changeset/expo-native-custom-pages.md b/.changeset/expo-native-custom-pages.md index 4159edd8097..a24f92def1c 100644 --- a/.changeset/expo-native-custom-pages.md +++ b/.changeset/expo-native-custom-pages.md @@ -2,4 +2,28 @@ '@clerk/expo': minor --- -Add custom user profile pages to the native `UserProfileView` and `UserButton` components. +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/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 42ec5d09053..c159d70c343 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -722,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) { @@ -851,11 +851,11 @@ private struct ClerkReactUserProfileCustomPage: View { private struct ClerkReactCustomPageView: UIViewRepresentable { let view: UIView - func makeUIView(context: Context) -> ClerkReactLogoContainerView { - ClerkReactLogoContainerView(contentView: view) + func makeUIView(context: Context) -> ClerkReactContentContainerView { + ClerkReactContentContainerView(contentView: view) } - func updateUIView(_ uiView: ClerkReactLogoContainerView, context: Context) { + 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..2c97d2be773 100644 --- a/packages/expo/ios/ClerkNativeViewHost.swift +++ b/packages/expo/ios/ClerkNativeViewHost.swift @@ -82,6 +82,60 @@ public class ClerkNativeViewHost: ExpoView { } } +public class ClerkUserProfileCustomPageHost: ClerkNativeViewHost { + private var currentCustomPages: String = "[]" + let customPageState = ClerkUserProfileCustomPageState() + let onCustomPageEvent = EventDispatcher() + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + customPageState.setPageEventHandler { [weak self] type, path in + self?.onCustomPageEvent(["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 c4e3e09d139..826fc695f80 100644 --- a/packages/expo/ios/ClerkUserButtonNativeView.swift +++ b/packages/expo/ios/ClerkUserButtonNativeView.swift @@ -1,55 +1,10 @@ import ExpoModulesCore import UIKit -public class ClerkUserButtonNativeView: ClerkNativeViewHost { - private var currentCustomPages: String = "[]" - private let customPageState = ClerkUserProfileCustomPageState() - let onCustomPageEvent = EventDispatcher() - - 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) - } - -#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 - +public class ClerkUserButtonNativeView: ClerkUserProfileCustomPageHost { override func makeHostedController() -> UIViewController? { - customPageState.setPageEventHandler { [weak self] type, path in - self?.onCustomPageEvent(["type": type, "path": path]) - } - return ClerkNativeBridge.shared.makeUserButtonViewController( - customRows: parseUserProfileCustomPages(currentCustomPages, pageCount: customPageState.views.count), + customRows: customRows(), customPageState: customPageState ) } diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo/ios/ClerkUserProfileNativeView.swift index 82e354f44ab..5faf2f9ec5b 100644 --- a/packages/expo/ios/ClerkUserProfileNativeView.swift +++ b/packages/expo/ios/ClerkUserProfileNativeView.swift @@ -1,15 +1,12 @@ import ExpoModulesCore import UIKit -public class ClerkUserProfileNativeView: ClerkNativeViewHost { +public class ClerkUserProfileNativeView: ClerkUserProfileCustomPageHost { private var currentDismissible: Bool = true private var currentHostBackButton: Bool = false - private var currentCustomPages: String = "[]" - private let customPageState = ClerkUserProfileCustomPageState() private var didSendDismiss = false let onProfileEvent = EventDispatcher() - let onCustomPageEvent = EventDispatcher() let onHostBack = EventDispatcher() func setDismissible(_ isDismissible: Bool?) { @@ -26,43 +23,6 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost { setNeedsHostedViewUpdate() } - 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) - } - -#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 func sendProfileEvent(type: ClerkNativeViewEvent) { onProfileEvent(["type": type.rawValue]) } @@ -83,17 +43,13 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost { } override func makeHostedController() -> UIViewController? { - customPageState.setPageEventHandler { [weak self] type, path in - self?.onCustomPageEvent(["type": type, "path": path]) - } - let hostBackAction: (() -> Void)? = currentHostBackButton ? { [weak self] in self?.onHostBack([:]) } : nil return ClerkNativeBridge.shared.makeUserProfileViewController( dismissible: currentDismissible, - customRows: parseUserProfileCustomPages(currentCustomPages, pageCount: customPageState.views.count), + customRows: customRows(), customPageState: customPageState, hostBackAction: hostBackAction, onEvent: { [weak self] event, _ in diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx index 5c057c3f99a..f5ed2bc8b32 100644 --- a/packages/expo/src/native/UserButton.tsx +++ b/packages/expo/src/native/UserButton.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, ComponentType, Ref } from 'react'; +import type { ComponentProps, ComponentType, JSX, Ref } from 'react'; import { useRef } from 'react'; import type { NativeSyntheticEvent } from 'react-native'; import { StyleSheet, useWindowDimensions } from 'react-native'; @@ -59,7 +59,7 @@ export interface UserButtonProps { * @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({ userProfileProps }: UserButtonProps) { +export function UserButton({ userProfileProps }: UserButtonProps): JSX.Element | null { const nativeViewRef = useRef(null); const customPages = userProfileProps?.customPages ?? []; const { activePath, onCustomPageEvent } = useUserProfileCustomPages(customPages, nativeViewRef); diff --git a/packages/expo/src/native/UserProfileCustomPages.tsx b/packages/expo/src/native/UserProfileCustomPages.tsx index 5c62cff7dcc..a990971cc80 100644 --- a/packages/expo/src/native/UserProfileCustomPages.tsx +++ b/packages/expo/src/native/UserProfileCustomPages.tsx @@ -96,6 +96,16 @@ export function useUserProfileCustomPageNavigation(): UserProfileCustomPageNavig } 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, @@ -134,7 +144,9 @@ export function useUserProfileCustomPages( openingPaths.current.add(path); void Linking.openURL(page.href) - .catch(() => undefined) + .catch(error => { + console.warn(`Could not open custom user profile page "${path}".`, error); + }) .finally(() => { openingPaths.current.delete(path); void navigationHandleRef.current?.navigateCustomPage('back'); diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo/src/native/UserProfileView.tsx index 6c3a4d65acd..dff4e86d9e6 100644 --- a/packages/expo/src/native/UserProfileView.tsx +++ b/packages/expo/src/native/UserProfileView.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, ComponentType, Ref } from 'react'; +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'; @@ -101,7 +101,7 @@ export function UserProfileView({ onDismiss, onHostBack, customPages = [], -}: UserProfileViewProps) { +}: UserProfileViewProps): JSX.Element { const nativeViewRef = useRef(null); const { activePath, onCustomPageEvent } = useUserProfileCustomPages(customPages, nativeViewRef); const handleProfileEvent = useCallback( diff --git a/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx b/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx index 89cbb67ebac..c34c01eed86 100644 --- a/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx +++ b/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx @@ -49,4 +49,13 @@ describe('serializeUserProfileCustomPages', () => { 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 d6139f32706..8e7bd6a5f1f 100644 --- a/packages/expo/src/native/__tests__/UserProfileView.test.tsx +++ b/packages/expo/src/native/__tests__/UserProfileView.test.tsx @@ -133,4 +133,23 @@ describe('UserProfileView', () => { 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(); + }); }); From 012643cf871367e8319f4fe5fad3bc7996f1eb06 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 13 Aug 2026 21:15:50 -0700 Subject: [PATCH 03/10] test(expo): Cover UserProfileView custom pages in the Expo native fixture --- integration/templates/expo-native/App.tsx | 26 ++++++++++ .../flows/user-profile-custom-pages.yaml | 52 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 integration/tests/expo-native/flows/user-profile-custom-pages.yaml 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..1df1d951b7f --- /dev/null +++ b/integration/tests/expo-native/flows/user-profile-custom-pages.yaml @@ -0,0 +1,52 @@ +# A custom page's React Native content is mounted as a child of the native +# host and rehosted into the destination that the custom row pushes. Asserts +# the row renders from the serialized config, the RN content appears inside +# the native screen, and the profile root still has the row after going 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 (not assertVisible): the row list is built from the serialized +# customPages prop, which can land a frame after the native profile paints. +- extendedWaitUntil: + visible: 'E2E Custom Page' + timeout: 20000 +- tapOn: + text: 'E2E Custom Page' +# The assertion that matters: the RN child is rehosted into the native +# destination rather than left in the host's own view hierarchy. +- extendedWaitUntil: + visible: 'Rehosted RN body' + timeout: 15000 +# Leaving the custom page differs per platform: clerk-ios pushes it onto its +# own NavigationStack so it gets the standard back chevron, while the Android +# destination is a bare AndroidView with no chrome, so system back is the only +# way out. +- 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: this one 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 From 3619c15dbf9ca79592495fb4c03a50455c5c7467 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 13 Aug 2026 21:17:10 -0700 Subject: [PATCH 04/10] chore: clean up e2e comments --- .../flows/user-profile-custom-pages.yaml | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/integration/tests/expo-native/flows/user-profile-custom-pages.yaml b/integration/tests/expo-native/flows/user-profile-custom-pages.yaml index 1df1d951b7f..0b408084b51 100644 --- a/integration/tests/expo-native/flows/user-profile-custom-pages.yaml +++ b/integration/tests/expo-native/flows/user-profile-custom-pages.yaml @@ -1,7 +1,5 @@ -# A custom page's React Native content is mounted as a child of the native -# host and rehosted into the destination that the custom row pushes. Asserts -# the row renders from the serialized config, the RN content appears inside -# the native screen, and the profile root still has the row after going back. +# 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 --- @@ -12,22 +10,17 @@ name: UserProfileView renders a custom page - runFlow: subflows/assert-signed-in.yaml - tapOn: id: 'open-embedded-profile-button' -# Retrying wait (not assertVisible): the row list is built from the serialized -# customPages prop, which can land a frame after the native profile paints. +# 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' -# The assertion that matters: the RN child is rehosted into the native -# destination rather than left in the host's own view hierarchy. - extendedWaitUntil: visible: 'Rehosted RN body' timeout: 15000 -# Leaving the custom page differs per platform: clerk-ios pushes it onto its -# own NavigationStack so it gets the standard back chevron, while the Android -# destination is a bare AndroidView with no chrome, so system back is the only -# way out. +# 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 @@ -41,7 +34,7 @@ name: UserProfileView renders a custom page - extendedWaitUntil: visible: 'E2E Custom Page' timeout: 15000 -# The host chevron firing onHostBack: this one reads 'Back' on both platforms. +# The host chevron firing onHostBack, which reads 'Back' on both platforms. - tapOn: 'Back' - extendedWaitUntil: visible: From 32431dd97239b4bfcb55bac44aadb2302c36d8ef Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 13 Aug 2026 22:56:05 -0700 Subject: [PATCH 05/10] fix(expo): give Android custom page hosts real layout bounds React Native views never self-measure, so Compose measured the AndroidView interop holder as zero and the rehosted subtree inherited empty bounds. The content still painted because RN lays it out from Yoga, but it was unreachable by accessibility and not clipped to the pushed destination. --- .../clerk/ClerkUserProfileViewModule.kt | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) 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 be2dc881bd0..39e12a1be68 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 @@ -6,6 +6,7 @@ import android.content.Context import android.util.Log import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -34,6 +35,12 @@ import org.json.JSONObject private const val TAG = "ClerkUserProfileViewModule" +private fun matchParentLayoutParams() = + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + private fun debugLog(tag: String, message: String) { if (BuildConfig.DEBUG) { Log.d(tag, message) @@ -186,9 +193,17 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle AndroidView( modifier = Modifier.fillMaxSize(), - factory = { + // React Native views never self-measure, so Compose sizes the interop holder to + // zero and the rehosted subtree ends up with no layout bounds. + factory = { context -> (view.parent as? ViewGroup)?.removeView(view) - view + FrameLayout(context).apply { addView(view, matchParentLayoutParams()) } + }, + update = { holder -> + if (view.parent !== holder) { + (view.parent as? ViewGroup)?.removeView(view) + holder.addView(view, matchParentLayoutParams()) + } }, ) } From 0b76525a1e01cae470544c409ded44e2b7c07779 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 13 Aug 2026 23:19:48 -0700 Subject: [PATCH 06/10] Revert "fix(expo): give Android custom page hosts real layout bounds" This reverts commit 32431dd97239b4bfcb55bac44aadb2302c36d8ef. --- .../clerk/ClerkUserProfileViewModule.kt | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) 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 39e12a1be68..be2dc881bd0 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 @@ -6,7 +6,6 @@ import android.content.Context import android.util.Log import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -35,12 +34,6 @@ import org.json.JSONObject private const val TAG = "ClerkUserProfileViewModule" -private fun matchParentLayoutParams() = - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - private fun debugLog(tag: String, message: String) { if (BuildConfig.DEBUG) { Log.d(tag, message) @@ -193,17 +186,9 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle AndroidView( modifier = Modifier.fillMaxSize(), - // React Native views never self-measure, so Compose sizes the interop holder to - // zero and the rehosted subtree ends up with no layout bounds. - factory = { context -> + factory = { (view.parent as? ViewGroup)?.removeView(view) - FrameLayout(context).apply { addView(view, matchParentLayoutParams()) } - }, - update = { holder -> - if (view.parent !== holder) { - (view.parent as? ViewGroup)?.removeView(view) - holder.addView(view, matchParentLayoutParams()) - } + view }, ) } From a319adf3d0525c1ce9324b78b49852ff7da7c5de Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 13 Aug 2026 23:24:48 -0700 Subject: [PATCH 07/10] fix(expo): restore custom page events on iOS Expo resolves event dispatchers with Mirror(reflecting:).children, which does not include inherited properties. Moving onCustomPageEvent onto the shared ClerkUserProfileCustomPageHost base class left it unbound, so custom page presented/dismissed events never reached JS and the page content never mounted. --- packages/expo/ios/ClerkNativeViewHost.swift | 7 +++++-- packages/expo/ios/ClerkUserButtonNativeView.swift | 4 ++++ packages/expo/ios/ClerkUserProfileNativeView.swift | 3 +++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo/ios/ClerkNativeViewHost.swift index 2c97d2be773..75347de32f8 100644 --- a/packages/expo/ios/ClerkNativeViewHost.swift +++ b/packages/expo/ios/ClerkNativeViewHost.swift @@ -85,12 +85,15 @@ public class ClerkNativeViewHost: ExpoView { public class ClerkUserProfileCustomPageHost: ClerkNativeViewHost { private var currentCustomPages: String = "[]" let customPageState = ClerkUserProfileCustomPageState() - let onCustomPageEvent = EventDispatcher() + + // 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?.onCustomPageEvent(["type": type, "path": path]) + self?.customPageEventDispatcher?(["type": type, "path": path]) } } diff --git a/packages/expo/ios/ClerkUserButtonNativeView.swift b/packages/expo/ios/ClerkUserButtonNativeView.swift index 826fc695f80..7a02633f6d8 100644 --- a/packages/expo/ios/ClerkUserButtonNativeView.swift +++ b/packages/expo/ios/ClerkUserButtonNativeView.swift @@ -2,6 +2,10 @@ import ExpoModulesCore import UIKit public class ClerkUserButtonNativeView: ClerkUserProfileCustomPageHost { + let onCustomPageEvent = EventDispatcher() + + override var customPageEventDispatcher: EventDispatcher? { onCustomPageEvent } + override func makeHostedController() -> UIViewController? { return ClerkNativeBridge.shared.makeUserButtonViewController( customRows: customRows(), diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo/ios/ClerkUserProfileNativeView.swift index 5faf2f9ec5b..f2ca604d41f 100644 --- a/packages/expo/ios/ClerkUserProfileNativeView.swift +++ b/packages/expo/ios/ClerkUserProfileNativeView.swift @@ -8,6 +8,9 @@ public class ClerkUserProfileNativeView: ClerkUserProfileCustomPageHost { let onProfileEvent = EventDispatcher() let onHostBack = EventDispatcher() + let onCustomPageEvent = EventDispatcher() + + override var customPageEventDispatcher: EventDispatcher? { onCustomPageEvent } func setDismissible(_ isDismissible: Bool?) { let newDismissible = isDismissible ?? true From ded4f6983f56439753ae6b9c270ff584647f5c17 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 13 Aug 2026 23:50:51 -0700 Subject: [PATCH 08/10] fix(expo): give Android custom page hosts real layout bounds Compose derives the interop MeasureSpec from the layout params of the view returned by the AndroidView factory. React Native views never self-measure, so the default WRAP_CONTENT collapsed the holder to zero and the rehosted subtree inherited empty bounds, leaving the content unreachable by accessibility and unclipped by the destination. --- .../clerk/ClerkUserProfileViewModule.kt | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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 be2dc881bd0..517e72def8a 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 @@ -6,6 +6,7 @@ import android.content.Context import android.util.Log import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -34,6 +35,12 @@ import org.json.JSONObject private const val TAG = "ClerkUserProfileViewModule" +private fun matchParentLayoutParams() = + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + private fun debugLog(tag: String, message: String) { if (BuildConfig.DEBUG) { Log.d(tag, message) @@ -186,9 +193,21 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle AndroidView( modifier = Modifier.fillMaxSize(), - factory = { + // Compose derives the interop MeasureSpec from the returned view's layout params. + // React Native views never self-measure, so without MATCH_PARENT here the holder + // collapses to zero and the rehosted subtree ends up with no layout bounds. + factory = { context -> (view.parent as? ViewGroup)?.removeView(view) - view + FrameLayout(context).apply { + layoutParams = matchParentLayoutParams() + addView(view, matchParentLayoutParams()) + } + }, + update = { holder -> + if (view.parent !== holder) { + (view.parent as? ViewGroup)?.removeView(view) + holder.addView(view, matchParentLayoutParams()) + } }, ) } From 6d0d1b1e1b4fa3837cef155533946b264807079f Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 14 Aug 2026 00:07:33 -0700 Subject: [PATCH 09/10] Revert "fix(expo): give Android custom page hosts real layout bounds" This reverts commit ded4f6983f56439753ae6b9c270ff584647f5c17. --- .../clerk/ClerkUserProfileViewModule.kt | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) 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 517e72def8a..be2dc881bd0 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 @@ -6,7 +6,6 @@ import android.content.Context import android.util.Log import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -35,12 +34,6 @@ import org.json.JSONObject private const val TAG = "ClerkUserProfileViewModule" -private fun matchParentLayoutParams() = - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - private fun debugLog(tag: String, message: String) { if (BuildConfig.DEBUG) { Log.d(tag, message) @@ -193,21 +186,9 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle AndroidView( modifier = Modifier.fillMaxSize(), - // Compose derives the interop MeasureSpec from the returned view's layout params. - // React Native views never self-measure, so without MATCH_PARENT here the holder - // collapses to zero and the rehosted subtree ends up with no layout bounds. - factory = { context -> + factory = { (view.parent as? ViewGroup)?.removeView(view) - FrameLayout(context).apply { - layoutParams = matchParentLayoutParams() - addView(view, matchParentLayoutParams()) - } - }, - update = { holder -> - if (view.parent !== holder) { - (view.parent as? ViewGroup)?.removeView(view) - holder.addView(view, matchParentLayoutParams()) - } + view }, ) } From bc95f0de702ca6419c1341e998b65284fa2e6bd8 Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 14 Aug 2026 13:13:20 -0700 Subject: [PATCH 10/10] fix(expo): lay out Android custom page hosts --- .../modules/clerk/ClerkComposeNativeViewHost.kt | 16 ++++++++++++++++ .../modules/clerk/ClerkUserButtonViewModule.kt | 5 ++++- .../modules/clerk/ClerkUserProfileViewModule.kt | 5 ++++- 3 files changed, 24 insertions(+), 2 deletions(-) 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 1aa50e7b74c..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 @@ -77,7 +77,10 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler val rows = customRows() val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return - LaunchedEffect(routeKey) { sendCustomPageEvent("presented", routeKey) } + LaunchedEffect(routeKey) { + layoutAndroidViewHandler(view) + sendCustomPageEvent("presented", routeKey) + } DisposableEffect(routeKey) { onDispose { sendCustomPageEvent("dismissed", routeKey) } } 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 be2dc881bd0..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 @@ -179,7 +179,10 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle val rows = customRows() val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return - LaunchedEffect(routeKey) { sendCustomPageEvent("presented", routeKey) } + LaunchedEffect(routeKey) { + layoutAndroidViewHandler(view) + sendCustomPageEvent("presented", routeKey) + } DisposableEffect(routeKey) { onDispose { sendCustomPageEvent("dismissed", routeKey) } }