diff --git a/.changeset/fresh-tabs-remember.md b/.changeset/fresh-tabs-remember.md new file mode 100644 index 00000000000..a611982e888 --- /dev/null +++ b/.changeset/fresh-tabs-remember.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Preserve native user profile custom pages across tab switches and display their labels as native navigation titles on iOS. diff --git a/.changeset/tidy-destinations-push.md b/.changeset/tidy-destinations-push.md new file mode 100644 index 00000000000..cb45d1d60b9 --- /dev/null +++ b/.changeset/tidy-destinations-push.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': minor +--- + +Add push-only `customDestinations` to the native `UserProfileView` and `UserButton`, allowing custom profile pages to navigate forward without adding another row to the profile root. diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle index a05ecda649c..329a30ff397 100644 --- a/packages/expo/android/build.gradle +++ b/packages/expo/android/build.gradle @@ -102,6 +102,8 @@ try { } dependencies { + testImplementation "junit:junit:4.13.2" + implementation project(':expo-modules-core') // Coroutines for async operations 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 9aea1bb96ce..b294604ca3c 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 @@ -9,6 +9,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Recomposer import androidx.compose.ui.platform.AndroidUiDispatcher import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.setViewTreeLifecycleOwner @@ -22,13 +23,23 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch -abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppContext) : ExpoView(context, appContext) { +abstract class ClerkComposeNativeViewHost( + context: Context, + appContext: AppContext, + private val retainCompositionOnDetach: Boolean = false, +) : ExpoView(context, appContext) { protected val activity: ComponentActivity? = findActivity(context) private var recomposer: Recomposer? = null private var recomposerJob: Job? = null private val composeView = ComposeView(context).also { view -> + if (retainCompositionOnDetach) { + // Native tab navigators detach inactive screens from the window. Keep the + // composition alive until React actually destroys this Expo view so Clerk's + // internal navigation back stack survives a tab switch. + view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + } activity?.let { act -> view.setViewTreeLifecycleOwner(act) view.setViewTreeViewModelStoreOwner(act) @@ -48,14 +59,19 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte } override fun onDetachedFromWindow() { - recomposer?.cancel() - recomposerJob?.cancel() - recomposer = null - recomposerJob = null + if (!retainCompositionOnDetach) { + stopRecomposer() + } onHostDetachedFromWindow() super.onDetachedFromWindow() } + fun destroyHost() { + composeView.disposeComposition() + stopRecomposer() + onHostDestroyed() + } + private fun startRecomposer() { if (activity == null || recomposerJob?.isActive == true) return @@ -69,6 +85,13 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte } } + private fun stopRecomposer() { + recomposer?.cancel() + recomposerJob?.cancel() + recomposer = null + recomposerJob = null + } + fun setupView() { startRecomposer() composeView.setContent { @@ -92,6 +115,8 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte protected open fun onHostDetachedFromWindow() {} + protected open fun onHostDestroyed() {} + protected fun layoutAndroidViewHandler(view: View) { view.post { val holder = view.parent as? View ?: return@post 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 7047d5a4db4..9fdbe0e2e03 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 @@ -8,12 +8,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 @@ -22,10 +23,13 @@ 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 var customPagesJson: String = "[]" private val customPageViews = mutableListOf() - private var customNavigator: UserProfileCustomNavigator? = null private val onCustomPageEvent by EventDispatcher() + private val customPageState = + ClerkUserProfileCustomPageState(resetCoveredPathsWhenInactive = true) { type, path -> + onCustomPageEvent(mapOf("type" to type, "path" to path)) + } init { activity?.let { Clerk.attachActivity(it) } @@ -33,6 +37,10 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler @Composable override fun Content() { + val user by Clerk.userFlow.collectAsStateWithLifecycle() + + LaunchedEffect(user?.id) { customPageState.userDidChange(user?.id) } + Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, @@ -63,26 +71,41 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler fun customPageCount(): Int = customPageViews.size + fun setCustomPages(customPages: String) { + if (customPagesJson == customPages) return + val validPaths = runCatching { userProfileCustomPagePaths(customPages) }.getOrDefault(emptySet()) + customPageState.reconcileCustomPagePaths(validPaths) + customPagesJson = customPages + } + fun navigateCustomPage(action: String, routeKey: String?) { - when (action) { - "back" -> customNavigator?.navigateBack() - "popToRoot" -> customNavigator?.popToRoot() - "push" -> routeKey?.let { customNavigator?.push(it) } - } + customPageState.navigate(action, routeKey) } @Composable private fun CustomPageDestination(routeKey: String) { - customNavigator = LocalUserProfileCustomNavigator.current - val rows = customRows() - val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return - - LaunchedEffect(routeKey) { + val customNavigator = LocalUserProfileCustomNavigator.current + val pages = customPages() + val view = customPageViews.getOrNull(pages.indexOfFirst { it.routeKey == routeKey }) + + LaunchedEffect(routeKey, customNavigator, view) { + customPageState.configureNavigation( + navigateBack = customNavigator::navigateBack, + popToRoot = customNavigator::popToRoot, + push = customNavigator::push, + ) + if (view == null) { + customNavigator.popToRoot() + return@LaunchedEffect + } layoutAndroidViewHandler(view) - sendCustomPageEvent("presented", routeKey) + customPageState.pageDidPresent(routeKey) } + + if (view == null) return + DisposableEffect(routeKey) { - onDispose { sendCustomPageEvent("dismissed", routeKey) } + onDispose { customPageState.pageDidDismiss(routeKey) } } AndroidView( @@ -94,12 +117,10 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler ) } - private fun customRows(): List = - runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList()) + private fun customRows(): List = customPages().filter { it.showAsRow }.map { it.nativeRow } - private fun sendCustomPageEvent(type: String, path: String) { - onCustomPageEvent(mapOf("type" to type, "path" to path)) - } + private fun customPages(): List = + runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList()) } class ClerkUserButtonViewModule : Module() { @@ -118,7 +139,7 @@ class ClerkUserButtonViewModule : Module() { } Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String -> - view.customPagesJson = customPages + view.setCustomPages(customPages) } AsyncFunction("navigateCustomPage") { diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt new file mode 100644 index 00000000000..6f7c2e86a0b --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt @@ -0,0 +1,120 @@ +package expo.modules.clerk + +import android.os.Handler +import android.os.Looper + +private fun postToMainThread(action: () -> Unit) { + Handler(Looper.getMainLooper()).post { action() } +} + +internal class ClerkUserProfileCustomPageState( + private val resetCoveredPathsWhenInactive: Boolean = false, + private val postInactiveReset: ((() -> Unit) -> Unit) = ::postToMainThread, + private val pageEventHandler: (type: String, path: String) -> Unit, +) { + private val retainedPaths = mutableListOf() + private var navigateBackAction: (() -> Unit)? = null + private var popToRootAction: (() -> Unit)? = null + private var pushAction: ((String) -> Unit)? = null + private var hasObservedUserId = false + private var observedUserId: String? = null + private var navigationTransitionGeneration = 0 + + fun configureNavigation( + navigateBack: () -> Unit, + popToRoot: () -> Unit, + push: (String) -> Unit, + ) { + navigateBackAction = navigateBack + popToRootAction = popToRoot + pushAction = push + } + + fun pageDidPresent(path: String) { + cancelPendingInactiveReset() + if (path !in retainedPaths) { + retainedPaths.add(path) + } + pageEventHandler("presented", path) + } + + fun pageDidDismiss(path: String) { + val pathIndex = retainedPaths.lastIndexOf(path) + if (pathIndex == -1) return + + // Navigation 3 disposes a destination when another page covers it. Keep that + // earlier path until it is actually removed from the native back stack. + if (pathIndex == retainedPaths.lastIndex) { + retainedPaths.removeAt(pathIndex) + if (resetCoveredPathsWhenInactive) { + scheduleInactiveReset() + } + } + pageEventHandler("dismissed", path) + } + + fun userDidChange(userId: String?) { + if (!hasObservedUserId) { + observedUserId = userId + hasObservedUserId = true + return + } + + if (observedUserId == userId) return + observedUserId = userId + invalidateNavigation() + } + + fun reconcileCustomPagePaths(validPaths: Set) { + if (retainedPaths.all(validPaths::contains)) return + invalidateNavigation() + } + + fun navigate(action: String, routeKey: String?) { + when (action) { + "back" -> navigateBackAction?.invoke() + "popToRoot" -> invalidateNavigation() + "push" -> { + val path = routeKey ?: return + val push = pushAction ?: return + if (path in retainedPaths) return + retainedPaths.add(path) + push(path) + } + } + } + + internal fun retainedPathsForTesting(): List = retainedPaths.toList() + + private fun invalidateNavigation() { + cancelPendingInactiveReset() + if (retainedPaths.isEmpty()) return + + val dismissedPaths = retainedPaths.asReversed().distinct() + retainedPaths.clear() + popToRootAction?.invoke() + dismissedPaths.forEach { pageEventHandler("dismissed", it) } + } + + private fun scheduleInactiveReset() { + val generation = ++navigationTransitionGeneration + postInactiveReset { + if (generation == navigationTransitionGeneration) { + resetInactivePaths() + } + } + } + + private fun cancelPendingInactiveReset() { + navigationTransitionGeneration += 1 + } + + private fun resetInactivePaths() { + navigateBackAction = null + popToRootAction = null + pushAction = null + val dismissedPaths = retainedPaths.asReversed().toList() + retainedPaths.clear() + dismissedPaths.forEach { pageEventHandler("dismissed", it) } + } +} 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 c8a853fa846..2e34a17d2b3 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 @@ -10,10 +10,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.clerk.api.Clerk import com.clerk.api.FrameworkIntegrationApi import com.clerk.ui.R @@ -40,23 +42,53 @@ private fun debugLog(tag: String, message: String) { } } -internal fun parseUserProfileCustomPages(customPagesJson: String, customPageCount: Int): List { +internal data class ClerkUserProfileCustomPageConfig( + val routeKey: String, + val title: String, + val icon: Int, + val placement: UserProfileCustomRowPlacement, + val showAsRow: Boolean, +) { + val nativeRow: UserProfileCustomRow + get() = + UserProfileCustomRow( + routeKey = routeKey, + title = title, + icon = UserProfileRowIcon.Resource(icon), + placement = placement, + ) +} + +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( + ClerkUserProfileCustomPageConfig( routeKey = page.getString("path"), title = page.getString("label"), - icon = UserProfileRowIcon.Resource(userProfileCustomRowIcon(page.optString("icon"))), + icon = userProfileCustomRowIcon(page.optString("icon")), placement = userProfileCustomRowPlacement(page.optJSONObject("placement")), + showAsRow = page.optBoolean("showAsRow", true), ), ) } } } +internal fun userProfileCustomPagePaths(customPagesJson: String): Set { + val pages = JSONArray(customPagesJson) + return buildSet { + for (index in 0 until pages.length()) { + add(pages.getJSONObject(index).getString("path")) + } + } +} + private fun userProfileCustomRowIcon(icon: String): Int = when (icon) { "user" -> R.drawable.ic_user @@ -100,16 +132,20 @@ private fun userProfileRow(row: String): UserProfileRow = else -> UserProfileRow.ManageAccount } -class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) { +class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : + ClerkComposeNativeViewHost(context, appContext, retainCompositionOnDetach = true) { // clerk-android UserProfileView dismissibility is controlled by its onDismiss callback. var isDismissible: Boolean = true var hostBackButton: Boolean = false - var customPagesJson: String = "[]" + private 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 customPageState = + ClerkUserProfileCustomPageState { type, path -> + onCustomPageEvent(mapOf("type" to type, "path" to path)) + } private val viewModelStoreOwner = object : ViewModelStoreOwner { private val store = ViewModelStore() @@ -118,7 +154,7 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle override fun localViewModelStoreOwner(): ViewModelStoreOwner = viewModelStoreOwner - override fun onHostDetachedFromWindow() { + override fun onHostDestroyed() { viewModelStoreOwner.viewModelStore.clear() } @@ -135,6 +171,10 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle @Composable private fun ProfileView() { + val user by Clerk.userFlow.collectAsStateWithLifecycle() + + LaunchedEffect(user?.id) { customPageState.userDidChange(user?.id) } + UserProfileView( clerkTheme = Clerk.customTheme, customRows = customRows(), @@ -165,26 +205,41 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle fun customPageCount(): Int = customPageViews.size + fun setCustomPages(customPages: String) { + if (customPagesJson == customPages) return + val validPaths = runCatching { userProfileCustomPagePaths(customPages) }.getOrDefault(emptySet()) + customPageState.reconcileCustomPagePaths(validPaths) + customPagesJson = customPages + } + fun navigateCustomPage(action: String, routeKey: String?) { - when (action) { - "back" -> customNavigator?.navigateBack() - "popToRoot" -> customNavigator?.popToRoot() - "push" -> routeKey?.let { customNavigator?.push(it) } - } + customPageState.navigate(action, routeKey) } @Composable private fun CustomPageDestination(routeKey: String) { - customNavigator = LocalUserProfileCustomNavigator.current - val rows = customRows() - val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return - - LaunchedEffect(routeKey) { + val customNavigator = LocalUserProfileCustomNavigator.current + val pages = customPages() + val view = customPageViews.getOrNull(pages.indexOfFirst { it.routeKey == routeKey }) + + LaunchedEffect(routeKey, customNavigator, view) { + customPageState.configureNavigation( + navigateBack = customNavigator::navigateBack, + popToRoot = customNavigator::popToRoot, + push = customNavigator::push, + ) + if (view == null) { + customNavigator.popToRoot() + return@LaunchedEffect + } layoutAndroidViewHandler(view) - sendCustomPageEvent("presented", routeKey) + customPageState.pageDidPresent(routeKey) } + + if (view == null) return + DisposableEffect(routeKey) { - onDispose { sendCustomPageEvent("dismissed", routeKey) } + onDispose { customPageState.pageDidDismiss(routeKey) } } AndroidView( @@ -197,6 +252,10 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle } private fun customRows(): List { + return customPages().filter { it.showAsRow }.map { it.nativeRow } + } + + private fun customPages(): List { return runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) } @@ -209,10 +268,6 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle 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() { @@ -239,7 +294,7 @@ class ClerkUserProfileViewModule : Module() { } Prop("customPages") { view: ClerkUserProfileNativeView, customPages: String -> - view.customPagesJson = customPages + view.setCustomPages(customPages) } AsyncFunction("navigateCustomPage") { @@ -251,6 +306,10 @@ class ClerkUserProfileViewModule : Module() { OnViewDidUpdateProps { view: ClerkUserProfileNativeView -> view.setupView() } + + OnViewDestroys { view: ClerkUserProfileNativeView -> + view.destroyHost() + } } } } diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt b/packages/expo/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt new file mode 100644 index 00000000000..348dbee2012 --- /dev/null +++ b/packages/expo/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt @@ -0,0 +1,163 @@ +package expo.modules.clerk + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ClerkUserProfileCustomPageStateTest { + @Test + fun coveredPageRemainsRetainedUntilItIsActuallyPopped() { + val events = mutableListOf() + val pushedPaths = mutableListOf() + val state = state(events, push = pushedPaths::add) + + state.pageDidPresent("billing") + state.navigate("push", "invoice-details") + state.pageDidDismiss("billing") + state.pageDidPresent("invoice-details") + + assertEquals(listOf("billing", "invoice-details"), state.retainedPathsForTesting()) + assertEquals(listOf("invoice-details"), pushedPaths) + assertEquals( + listOf("presented:billing", "dismissed:billing", "presented:invoice-details"), + events, + ) + + state.pageDidDismiss("invoice-details") + state.pageDidPresent("billing") + + assertEquals(listOf("billing"), state.retainedPathsForTesting()) + } + + @Test + fun closingUserButtonProfileDismissesCoveredPages() { + val events = mutableListOf() + val pendingResets = mutableListOf<() -> Unit>() + val state = + state( + events, + resetCoveredPathsWhenInactive = true, + postInactiveReset = pendingResets::add, + ) + state.pageDidPresent("billing") + state.navigate("push", "invoice-details") + state.pageDidDismiss("billing") + state.pageDidPresent("invoice-details") + events.clear() + + state.pageDidDismiss("invoice-details") + pendingResets.single().invoke() + + assertEquals(emptyList(), state.retainedPathsForTesting()) + assertEquals(listOf("dismissed:invoice-details", "dismissed:billing"), events) + } + + @Test + fun returningToCoveredUserButtonPageCancelsInactiveReset() { + val events = mutableListOf() + val pendingResets = mutableListOf<() -> Unit>() + val state = + state( + events, + resetCoveredPathsWhenInactive = true, + postInactiveReset = pendingResets::add, + ) + state.pageDidPresent("billing") + state.navigate("push", "invoice-details") + state.pageDidDismiss("billing") + state.pageDidPresent("invoice-details") + events.clear() + + state.pageDidDismiss("invoice-details") + state.pageDidPresent("billing") + pendingResets.single().invoke() + + assertEquals(listOf("billing"), state.retainedPathsForTesting()) + assertEquals(listOf("dismissed:invoice-details", "presented:billing"), events) + } + + @Test + fun pushingAPathAlreadyInTheStackDoesNotPushOrCollapseIt() { + val events = mutableListOf() + val pushedPaths = mutableListOf() + val state = state(events, push = pushedPaths::add) + state.pageDidPresent("billing") + state.navigate("push", "invoice-details") + state.pageDidDismiss("billing") + state.pageDidPresent("invoice-details") + events.clear() + + state.navigate("push", "billing") + + assertEquals(listOf("billing", "invoice-details"), state.retainedPathsForTesting()) + assertEquals(listOf("invoice-details"), pushedPaths) + assertEquals(emptyList(), events) + } + + @Test + fun popToRootDismissesEveryRetainedPageDeepestFirst() { + val events = mutableListOf() + var popToRootCount = 0 + val state = state(events, popToRoot = { popToRootCount += 1 }) + state.pageDidPresent("billing") + state.navigate("push", "invoice-details") + state.pageDidDismiss("billing") + state.pageDidPresent("invoice-details") + events.clear() + + state.navigate("popToRoot", null) + + assertEquals(emptyList(), state.retainedPathsForTesting()) + assertEquals(1, popToRootCount) + assertEquals(listOf("dismissed:invoice-details", "dismissed:billing"), events) + } + + @Test + fun removingAnEarlierRouteInvalidatesTheWholeStack() { + val events = mutableListOf() + var popToRootCount = 0 + val state = state(events, popToRoot = { popToRootCount += 1 }) + state.pageDidPresent("billing") + state.navigate("push", "invoice-details") + state.pageDidDismiss("billing") + state.pageDidPresent("invoice-details") + events.clear() + + state.reconcileCustomPagePaths(setOf("invoice-details")) + + assertEquals(emptyList(), state.retainedPathsForTesting()) + assertEquals(1, popToRootCount) + assertEquals(listOf("dismissed:invoice-details", "dismissed:billing"), events) + } + + @Test + fun changingUsersInvalidatesRetainedNavigationOnce() { + val events = mutableListOf() + var popToRootCount = 0 + val state = state(events, popToRoot = { popToRootCount += 1 }) + state.userDidChange("user_1") + state.pageDidPresent("billing") + events.clear() + + state.userDidChange(null) + state.userDidChange(null) + + assertEquals(emptyList(), state.retainedPathsForTesting()) + assertEquals(1, popToRootCount) + assertEquals(listOf("dismissed:billing"), events) + } + + private fun state( + events: MutableList, + popToRoot: () -> Unit = {}, + push: (String) -> Unit = {}, + resetCoveredPathsWhenInactive: Boolean = false, + postInactiveReset: ((() -> Unit) -> Unit) = { it() }, + ): ClerkUserProfileCustomPageState { + return ClerkUserProfileCustomPageState( + resetCoveredPathsWhenInactive = resetCoveredPathsWhenInactive, + postInactiveReset = postInactiveReset, + ) { type, path -> events.add("$type:$path") }.also { + it.configureNavigation(navigateBack = {}, popToRoot = popToRoot, push = push) + } + } +} diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index c159d70c343..f4f6169c7af 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -45,10 +45,38 @@ final class ClerkInlineAuthLogoState { @MainActor @Observable final class ClerkUserProfileCustomPageState { + typealias InactiveResetAction = @MainActor () -> Void + typealias PostInactiveReset = (@escaping InactiveResetAction) -> Void + + private struct PagePresentation { + let path: String + let navigationDepth: Int? + } + private(set) var views: [UIView] = [] - @ObservationIgnored private var navigator: UserProfileNavigator? @ObservationIgnored private var navigateBackAction: (() -> Void)? + @ObservationIgnored private var popToRootAction: (() -> Void)? + @ObservationIgnored private var pushAction: ((String) -> Void)? @ObservationIgnored private var pageEventHandler: ((String, String) -> Void)? + @ObservationIgnored private var pagePresentation: PagePresentation? + @ObservationIgnored private var retainedNavigationPath = NavigationPath() + @ObservationIgnored private var retainedCustomPagePathsByDepth: [Int: String] = [:] + @ObservationIgnored private var retainedNavigatorPaths: [String] = [] + @ObservationIgnored private var navigatorResetGeneration = 0 + @ObservationIgnored private var hasObservedUserID = false + @ObservationIgnored private var observedUserID: String? + private let postInactiveReset: PostInactiveReset + + init( + postInactiveReset: @escaping PostInactiveReset = { action in + Task { @MainActor in + await Task.yield() + action() + } + } + ) { + self.postInactiveReset = postInactiveReset + } func insertView(_ view: UIView, at index: Int) { view.removeFromSuperview() @@ -65,16 +93,111 @@ final class ClerkUserProfileCustomPageState { _ navigator: UserProfileNavigator, navigateBack: @escaping () -> Void ) { - self.navigator = navigator - navigateBackAction = navigateBack + configureNavigation( + navigateBack: navigateBack, + popToRoot: navigator.popToRoot, + push: navigator.push + ) + } + + func configureNavigation(_ navigationPath: Binding) { + configureNavigation( + navigateBack: { + guard !navigationPath.wrappedValue.isEmpty else { return } + navigationPath.wrappedValue.removeLast() + }, + popToRoot: { + navigationPath.wrappedValue = NavigationPath() + }, + push: { + navigationPath.wrappedValue.append($0) + } + ) } func setPageEventHandler(_ handler: @escaping (String, String) -> Void) { pageEventHandler = handler } - func sendPageEvent(type: String, path: String) { - pageEventHandler?(type, path) + func pageDidPresent(path: String, navigationDepth: Int? = nil) { + if navigationDepth == nil { + cancelPendingNavigatorReset() + retainNavigatorPath(path) + } + pagePresentation = PagePresentation(path: path, navigationDepth: navigationDepth) + if let navigationDepth { + retainedCustomPagePathsByDepth[navigationDepth] = path + } + pageEventHandler?("presented", path) + } + + func pageDidDismiss(path: String) { + guard pagePresentation?.path == path else { return } + let usesNavigator = pagePresentation?.navigationDepth == nil + if usesNavigator, retainedNavigatorPaths.last == path { + retainedNavigatorPaths.removeLast() + scheduleNavigatorResetIfInactive() + } + dismissPage(path) + } + + func navigationDepthDidChange(_ navigationDepth: Int) { + let removedPaths = retainedCustomPagePathsByDepth + .filter { $0.key > navigationDepth } + .sorted { $0.key > $1.key } + .map(\.value) + let remainingPathsByDepth = retainedCustomPagePathsByDepth.filter { + $0.key <= navigationDepth + } + let remainingPaths = Set(remainingPathsByDepth.values) + retainedCustomPagePathsByDepth = remainingPathsByDepth + + var dismissedPaths = Set() + for path in removedPaths where !remainingPaths.contains(path) && dismissedPaths.insert(path).inserted { + dismissPage(path) + } + + if let pagePresentation, + let presentedDepth = pagePresentation.navigationDepth, + navigationDepth < presentedDepth + { + self.pagePresentation = nil + } + } + + /// Expo can rebuild its hosting controller when a tab detaches. The live path starts + /// empty in each new controller so ClerkKitUI captures the correct zero-depth baseline, + /// while this retained snapshot is restored after that first appearance. + func navigationPathForRestoration() -> NavigationPath { + retainedNavigationPath + } + + func navigationPathDidChange(_ navigationPath: NavigationPath) { + retainedNavigationPath = navigationPath + navigationDepthDidChange(navigationPath.count) + } + + func userDidChange(to userID: String?) { + guard hasObservedUserID else { + observedUserID = userID + hasObservedUserID = true + return + } + + guard observedUserID != userID else { return } + observedUserID = userID + invalidateNavigation() + } + + func reconcileCustomPagePaths(_ validPaths: Set) { + var retainedPaths = Set(retainedCustomPagePathsByDepth.values) + retainedPaths.formUnion(retainedNavigatorPaths) + if let presentedPath = pagePresentation?.path { + retainedPaths.insert(presentedPath) + } + guard !retainedPaths.isSubset(of: validPaths) else { return } + + invalidateNavigation() } func navigate(action: String, routeKey: String?) { @@ -82,15 +205,102 @@ final class ClerkUserProfileCustomPageState { case "back": navigateBackAction?() case "popToRoot": - navigator?.popToRoot() + popToRootAction?() case "push": if let routeKey { - navigator?.push(routeKey) + guard !retainedCustomPagePathsByDepth.values.contains(routeKey), + !retainedNavigatorPaths.contains(routeKey) + else { return } + if let pagePresentation, pagePresentation.navigationDepth == nil { + retainNavigatorPath(routeKey) + } + pushAction?(routeKey) } default: break } } + + private func configureNavigation( + navigateBack: @escaping () -> Void, + popToRoot: @escaping () -> Void, + push: @escaping (String) -> Void + ) { + navigateBackAction = navigateBack + popToRootAction = popToRoot + pushAction = push + } + + private func invalidateNavigation() { + var dismissedPaths = retainedCustomPagePathsByDepth + .sorted { $0.key > $1.key } + .map(\.value) + dismissedPaths.append(contentsOf: retainedNavigatorPaths.reversed()) + if let presentedPath = pagePresentation?.path, + !dismissedPaths.contains(presentedPath) + { + dismissedPaths.insert(presentedPath, at: 0) + } + + retainedNavigationPath = NavigationPath() + retainedCustomPagePathsByDepth.removeAll() + retainedNavigatorPaths.removeAll() + cancelPendingNavigatorReset() + popToRootAction?() + var uniqueDismissedPaths: [String] = [] + for path in dismissedPaths where !uniqueDismissedPaths.contains(path) { + uniqueDismissedPaths.append(path) + } + for path in uniqueDismissedPaths { + dismissPage(path) + } + } + + private func retainNavigatorPath(_ path: String) { + guard let retainedIndex = retainedNavigatorPaths.lastIndex(of: path) else { + retainedNavigatorPaths.append(path) + return + } + + let removedPaths = Array(retainedNavigatorPaths.suffix(from: retainedIndex + 1).reversed()) + retainedNavigatorPaths.removeSubrange((retainedIndex + 1).. { UserProfileCustomRow( @@ -160,13 +375,24 @@ struct ClerkUserProfileCustomRowConfig: Decodable { } } -func parseUserProfileCustomPages(_ json: String, pageCount: Int) -> [ClerkUserProfileCustomRowConfig] { +func decodeUserProfileCustomPages(_ json: String) -> [ClerkUserProfileCustomRowConfig] { guard let data = json.data(using: .utf8), let rows = try? JSONDecoder().decode([ClerkUserProfileCustomRowConfig].self, from: data) else { return [] } - return Array(rows.prefix(pageCount)) + return rows +} + +func parseUserProfileCustomPages(_ json: String, pageCount: Int) -> [ClerkUserProfileCustomRowConfig] { + Array(decodeUserProfileCustomPages(json).prefix(pageCount)) +} + +func userProfileCustomPageLabel( + for path: String, + rows: [ClerkUserProfileCustomRowConfig] +) -> String { + rows.first(where: { $0.path == path })?.label ?? "" } private let clerkNativeClientEventQueue = DispatchQueue(label: "com.clerk.expo.native-client-events") @@ -417,6 +643,7 @@ final class ClerkNativeBridge { ) } + @MainActor func makeUserProfileViewController( dismissible: Bool, customRows: [ClerkUserProfileCustomRowConfig], @@ -434,11 +661,13 @@ final class ClerkNativeBridge { darkTheme: darkTheme, customRows: customRows, customPageState: customPageState - ), + ) + .environment(Clerk.shared), onDismiss: dismissible ? { onEvent(.dismissed, [:]) } : nil ) } + @MainActor func makeUserButtonViewController( customRows: [ClerkUserProfileCustomRowConfig], customPageState: ClerkUserProfileCustomPageState @@ -452,6 +681,7 @@ final class ClerkNativeBridge { customRows: customRows, customPageState: customPageState ) + .environment(Clerk.shared) ) } @@ -645,16 +875,21 @@ final class ClerkNativeBridge { // MARK: - Inline User Button Wrapper (for embedded rendering) struct ClerkInlineUserButtonWrapperView: View { + @Environment(Clerk.self) private var clerk + @Environment(\.colorScheme) private var colorScheme + let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? let customRows: [ClerkUserProfileCustomRowConfig] let customPageState: ClerkUserProfileCustomPageState - @Environment(\.colorScheme) private var colorScheme + private var userID: String? { + clerk.user?.id + } var body: some View { let view = UserButton() - .userProfileRows(customRows.map(\.nativeRow)) + .userProfileRows(customRows.filter(\.shouldShowAsRow).map(\.nativeRow)) .userProfileDestination { routeKey in ClerkReactUserProfileCustomPage( path: routeKey, @@ -662,7 +897,6 @@ struct ClerkInlineUserButtonWrapperView: View { state: customPageState ) } - .environment(Clerk.shared) let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme let themedView = Group { if let theme { @@ -673,6 +907,12 @@ struct ClerkInlineUserButtonWrapperView: View { } themedView .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .onAppear { + customPageState.userDidChange(to: userID) + } + .onChange(of: userID) { _, newUserID in + customPageState.userDidChange(to: newUserID) + } } } @@ -786,6 +1026,9 @@ private final class ClerkNativeHostingController: UIHostingContro // MARK: - Inline Profile View Wrapper (for embedded rendering) struct ClerkInlineProfileWrapperView: View { + @Environment(Clerk.self) private var clerk + @Environment(\.colorScheme) private var colorScheme + let dismissible: Bool let hostBackAction: ClerkHostBackAction? let lightTheme: ClerkTheme? @@ -793,20 +1036,30 @@ struct ClerkInlineProfileWrapperView: View { let customRows: [ClerkUserProfileCustomRowConfig] let customPageState: ClerkUserProfileCustomPageState - @Environment(\.colorScheme) private var colorScheme + @State private var navigationPath = NavigationPath() + @State private var didRestoreNavigation = false + + private var userID: String? { + clerk.user?.id + } var body: some View { - let view = UserProfileView(isDismissible: dismissible) - .userProfileRows(customRows.map(\.nativeRow)) - .userProfileDestination { routeKey in - ClerkReactUserProfileCustomPage( + let view = NavigationStack(path: $navigationPath) { + UserProfileView( + isDismissible: dismissible, + navigationPath: $navigationPath + ) + .userProfileRows(customRows.filter(\.shouldShowAsRow).map(\.nativeRow)) + .navigationDestination(for: String.self) { routeKey in + ClerkReactEmbeddedUserProfileCustomPage( path: routeKey, rows: customRows, - state: customPageState + state: customPageState, + navigationPath: $navigationPath ) } - .environment(Clerk.shared) - .environment(\.clerkHostBackAction, hostBackAction) + } + .environment(\.clerkHostBackAction, hostBackAction) let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme let themedView = Group { if let theme { @@ -816,6 +1069,26 @@ struct ClerkInlineProfileWrapperView: View { } } themedView + .onAppear { + customPageState.configureNavigation($navigationPath) + customPageState.userDidChange(to: userID) + } + .onChange(of: navigationPath.count) { _, _ in + customPageState.navigationPathDidChange(navigationPath) + } + .onChange(of: userID) { _, newUserID in + customPageState.userDidChange(to: newUserID) + } + .task(restoreNavigationIfNeeded) + } + + @MainActor + private func restoreNavigationIfNeeded() async { + guard !didRestoreNavigation else { return } + await Task.yield() + guard !Task.isCancelled else { return } + navigationPath = customPageState.navigationPathForRestoration() + didRestoreNavigation = true } } @@ -827,6 +1100,40 @@ private struct ClerkReactUserProfileCustomPage: View { let rows: [ClerkUserProfileCustomRowConfig] let state: ClerkUserProfileCustomPageState + var body: some View { + ClerkReactUserProfileCustomPageContent(path: path, rows: rows, state: state) + .onAppear { + state.configureNavigation(navigator) { + dismiss() + } + state.pageDidPresent(path: path) + } + .onDisappear { + state.pageDidDismiss(path: path) + } + } +} + +private struct ClerkReactEmbeddedUserProfileCustomPage: View { + let path: String + let rows: [ClerkUserProfileCustomRowConfig] + let state: ClerkUserProfileCustomPageState + @Binding var navigationPath: NavigationPath + + var body: some View { + ClerkReactUserProfileCustomPageContent(path: path, rows: rows, state: state) + .onAppear { + state.configureNavigation($navigationPath) + state.pageDidPresent(path: path, navigationDepth: navigationPath.count) + } + } +} + +private struct ClerkReactUserProfileCustomPageContent: View { + let path: String + let rows: [ClerkUserProfileCustomRowConfig] + let state: ClerkUserProfileCustomPageState + var body: some View { Group { if let index = rows.firstIndex(where: { $0.path == path }), @@ -836,15 +1143,8 @@ private struct ClerkReactUserProfileCustomPage: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - state.configureNavigation(navigator) { - dismiss() - } - state.sendPageEvent(type: "presented", path: path) - } - .onDisappear { - state.sendPageEvent(type: "dismissed", path: path) - } + .navigationTitle(userProfileCustomPageLabel(for: path, rows: rows)) + .navigationBarTitleDisplayMode(.inline) } } diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo/ios/ClerkNativeViewHost.swift index 75347de32f8..ee00c57fce2 100644 --- a/packages/expo/ios/ClerkNativeViewHost.swift +++ b/packages/expo/ios/ClerkNativeViewHost.swift @@ -100,6 +100,8 @@ public class ClerkUserProfileCustomPageHost: ClerkNativeViewHost { func setCustomPages(_ customPages: String?) { let newCustomPages = customPages ?? "[]" guard newCustomPages != currentCustomPages else { return } + let validPaths = Set(decodeUserProfileCustomPages(newCustomPages).map(\.path)) + customPageState.reconcileCustomPagePaths(validPaths) currentCustomPages = newCustomPages setNeedsHostedViewUpdate() } diff --git a/packages/expo/ios/Tests/ClerkUserProfileCustomPageStateTests.swift b/packages/expo/ios/Tests/ClerkUserProfileCustomPageStateTests.swift new file mode 100644 index 00000000000..04476eb8879 --- /dev/null +++ b/packages/expo/ios/Tests/ClerkUserProfileCustomPageStateTests.swift @@ -0,0 +1,311 @@ +@testable import ClerkExpo +import SwiftUI +import XCTest + +final class ClerkUserProfileCustomPageStateTests: XCTestCase { + @MainActor + func testNavigationPathCanBeRestoredIntoANewLiveHost() { + let state = ClerkUserProfileCustomPageState() + let navigationPath = makeNavigationPath("billing") + + state.navigationPathDidChange(navigationPath) + + XCTAssertEqual(state.navigationPathForRestoration().count, 1) + } + + @MainActor + func testKeepingTheSameNavigationDepthDoesNotDismissThePage() { + let state = ClerkUserProfileCustomPageState() + var events: [String] = [] + state.setPageEventHandler { type, path in + events.append("\(type):\(path)") + } + + state.pageDidPresent(path: "billing", navigationDepth: 1) + state.navigationDepthDidChange(1) + + XCTAssertEqual(events, ["presented:billing"]) + } + + @MainActor + func testIncreasingTheNavigationDepthDoesNotDismissThePage() { + let state = ClerkUserProfileCustomPageState() + var events: [String] = [] + state.setPageEventHandler { type, path in + events.append("\(type):\(path)") + } + + state.pageDidPresent(path: "billing", navigationDepth: 1) + state.navigationDepthDidChange(2) + + XCTAssertEqual(events, ["presented:billing"]) + } + + @MainActor + func testDecreasingTheNavigationDepthDismissesThePageOnce() { + let state = ClerkUserProfileCustomPageState() + var events: [String] = [] + state.setPageEventHandler { type, path in + events.append("\(type):\(path)") + } + + state.pageDidPresent(path: "billing", navigationDepth: 1) + state.navigationDepthDidChange(0) + state.navigationDepthDidChange(0) + + XCTAssertEqual(events, ["presented:billing", "dismissed:billing"]) + } + + @MainActor + func testInitialUserObservationKeepsTheRetainedPath() { + let state = ClerkUserProfileCustomPageState() + state.navigationPathDidChange(makeNavigationPath("billing")) + + state.userDidChange(to: "user_1") + + XCTAssertEqual(state.navigationPathForRestoration().count, 1) + } + + @MainActor + func testUserIdentityChangeInvalidatesNavigationAndDismissesThePageOnce() { + let state = ClerkUserProfileCustomPageState() + var events: [String] = [] + state.setPageEventHandler { type, path in + events.append("\(type):\(path)") + } + state.userDidChange(to: "user_1") + state.navigationPathDidChange(makeNavigationPath("billing")) + state.pageDidPresent(path: "billing", navigationDepth: 1) + + state.userDidChange(to: nil) + state.userDidChange(to: nil) + + XCTAssertEqual(state.navigationPathForRestoration().count, 0) + XCTAssertEqual(events, ["presented:billing", "dismissed:billing"]) + } + + @MainActor + func testKeepingTheSameUserPreservesNavigation() { + let state = ClerkUserProfileCustomPageState() + state.userDidChange(to: "user_1") + state.navigationPathDidChange(makeNavigationPath("billing")) + + state.userDidChange(to: "user_1") + + XCTAssertEqual(state.navigationPathForRestoration().count, 1) + } + + @MainActor + func testUserIdentityChangeInvalidatesTheEntireUserButtonStack() { + let state = ClerkUserProfileCustomPageState() + var events: [String] = [] + state.setPageEventHandler { type, path in + events.append("\(type):\(path)") + } + state.userDidChange(to: "user_1") + state.pageDidPresent(path: "billing") + state.navigate(action: "push", routeKey: "preferences") + state.pageDidDismiss(path: "billing") + state.pageDidPresent(path: "preferences") + events.removeAll() + + state.userDidChange(to: "user_2") + state.userDidChange(to: "user_2") + + XCTAssertEqual(events, ["dismissed:preferences", "dismissed:billing"]) + } + + @MainActor + func testRemovingThePresentedCustomPageInvalidatesNavigation() { + let state = ClerkUserProfileCustomPageState() + var events: [String] = [] + state.setPageEventHandler { type, path in + events.append("\(type):\(path)") + } + state.navigationPathDidChange(makeNavigationPath("billing")) + state.pageDidPresent(path: "billing", navigationDepth: 1) + + state.reconcileCustomPagePaths(["preferences"]) + + XCTAssertEqual(state.navigationPathForRestoration().count, 0) + XCTAssertEqual(events, ["presented:billing", "dismissed:billing"]) + } + + @MainActor + func testRemovingAnEarlierCustomPageInvalidatesTheRetainedStack() { + let state = ClerkUserProfileCustomPageState() + var events: [String] = [] + state.setPageEventHandler { type, path in + events.append("\(type):\(path)") + } + state.navigationPathDidChange(makeNavigationPath("billing", "preferences")) + state.pageDidPresent(path: "billing", navigationDepth: 1) + state.pageDidPresent(path: "preferences", navigationDepth: 2) + + state.reconcileCustomPagePaths(["preferences"]) + + XCTAssertEqual(state.navigationPathForRestoration().count, 0) + XCTAssertEqual( + events, + ["presented:billing", "presented:preferences", "dismissed:preferences", "dismissed:billing"] + ) + } + + @MainActor + func testRemovingAnEarlierUserButtonPageInvalidatesTheRetainedStack() { + let state = ClerkUserProfileCustomPageState() + var events: [String] = [] + state.setPageEventHandler { type, path in + events.append("\(type):\(path)") + } + state.pageDidPresent(path: "billing") + state.navigate(action: "push", routeKey: "preferences") + state.pageDidDismiss(path: "billing") + state.pageDidPresent(path: "preferences") + events.removeAll() + + state.reconcileCustomPagePaths(["preferences"]) + + XCTAssertEqual(events, ["dismissed:preferences", "dismissed:billing"]) + } + + @MainActor + func testClosingUserButtonProfileDismissesCoveredPages() { + var pendingResets: [ClerkUserProfileCustomPageState.InactiveResetAction] = [] + let state = ClerkUserProfileCustomPageState { pendingResets.append($0) } + var events: [String] = [] + state.setPageEventHandler { type, path in + events.append("\(type):\(path)") + } + state.pageDidPresent(path: "billing") + state.navigate(action: "push", routeKey: "preferences") + state.pageDidDismiss(path: "billing") + state.pageDidPresent(path: "preferences") + events.removeAll() + + state.pageDidDismiss(path: "preferences") + XCTAssertEqual(pendingResets.count, 1) + pendingResets.removeFirst()() + + XCTAssertEqual(events, ["dismissed:preferences", "dismissed:billing"]) + } + + @MainActor + func testReturningToCoveredUserButtonPageKeepsItRetained() { + var pendingResets: [ClerkUserProfileCustomPageState.InactiveResetAction] = [] + let state = ClerkUserProfileCustomPageState { pendingResets.append($0) } + var events: [String] = [] + state.setPageEventHandler { type, path in + events.append("\(type):\(path)") + } + state.pageDidPresent(path: "billing") + state.navigate(action: "push", routeKey: "preferences") + state.pageDidDismiss(path: "billing") + state.pageDidPresent(path: "preferences") + events.removeAll() + + state.pageDidDismiss(path: "preferences") + state.pageDidPresent(path: "billing") + XCTAssertEqual(pendingResets.count, 1) + pendingResets.removeFirst()() + state.reconcileCustomPagePaths(["billing"]) + + XCTAssertEqual(events, ["dismissed:preferences", "presented:billing"]) + } + + @MainActor + func testPushingAUserButtonPathAlreadyInTheStackDoesNotCollapseLaterPaths() { + let state = ClerkUserProfileCustomPageState() + var events: [String] = [] + state.setPageEventHandler { type, path in + events.append("\(type):\(path)") + } + state.pageDidPresent(path: "billing") + state.navigate(action: "push", routeKey: "preferences") + state.pageDidDismiss(path: "billing") + state.pageDidPresent(path: "preferences") + events.removeAll() + + state.navigate(action: "push", routeKey: "billing") + state.reconcileCustomPagePaths(["billing", "preferences"]) + + XCTAssertEqual(events, []) + } + + @MainActor + func testPoppingMultipleCustomPagesDismissesEachRetainedPage() { + let state = ClerkUserProfileCustomPageState() + var events: [String] = [] + state.setPageEventHandler { type, path in + events.append("\(type):\(path)") + } + state.pageDidPresent(path: "billing", navigationDepth: 1) + state.pageDidPresent(path: "preferences", navigationDepth: 2) + + state.navigationDepthDidChange(0) + + XCTAssertEqual( + events, + ["presented:billing", "presented:preferences", "dismissed:preferences", "dismissed:billing"] + ) + } + + @MainActor + func testPoppedCustomPagesAreNotIncludedInLaterReconciliation() { + let state = ClerkUserProfileCustomPageState() + state.navigationPathDidChange(makeNavigationPath("billing", "preferences")) + state.pageDidPresent(path: "billing", navigationDepth: 1) + state.pageDidPresent(path: "preferences", navigationDepth: 2) + + state.navigationPathDidChange(makeNavigationPath("billing")) + state.pageDidPresent(path: "billing", navigationDepth: 1) + state.reconcileCustomPagePaths(["billing"]) + + XCTAssertEqual(state.navigationPathForRestoration().count, 1) + } + + @MainActor + func testKeepingThePresentedCustomPagePreservesNavigation() { + let state = ClerkUserProfileCustomPageState() + state.navigationPathDidChange(makeNavigationPath("billing")) + state.pageDidPresent(path: "billing", navigationDepth: 1) + + state.reconcileCustomPagePaths(["billing", "preferences"]) + + XCTAssertEqual(state.navigationPathForRestoration().count, 1) + } + + func testCustomPageLabelComesFromTheMatchingRow() { + let rows = parseUserProfileCustomPages( + """ + [{"path":"billing","label":"Billing details","icon":"billing","placement":{"type":"sectionEnd","section":"profile"}}] + """, + pageCount: 1 + ) + + XCTAssertEqual(userProfileCustomPageLabel(for: "billing", rows: rows), "Billing details") + } + + func testPushOnlyDestinationsKeepTheirTitleWithoutCreatingRows() { + let pages = parseUserProfileCustomPages( + """ + [ + {"path":"billing","label":"Billing","icon":"billing","placement":{"type":"sectionEnd","section":"profile"}}, + {"path":"invoice-details","label":"Invoice details","icon":"settings","placement":{"type":"sectionEnd","section":"profile"},"showAsRow":false} + ] + """, + pageCount: 2 + ) + + XCTAssertEqual(pages.filter(\.shouldShowAsRow).map(\.path), ["billing"]) + XCTAssertEqual(userProfileCustomPageLabel(for: "invoice-details", rows: pages), "Invoice details") + } + + private func makeNavigationPath(_ routes: String...) -> NavigationPath { + var navigationPath = NavigationPath() + for route in routes { + navigationPath.append(route) + } + return navigationPath + } +} diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx index f5ed2bc8b32..a1406f0652e 100644 --- a/packages/expo/src/native/UserButton.tsx +++ b/packages/expo/src/native/UserButton.tsx @@ -1,5 +1,5 @@ import type { ComponentProps, ComponentType, JSX, Ref } from 'react'; -import { useRef } from 'react'; +import { useMemo, useRef } from 'react'; import type { NativeSyntheticEvent } from 'react-native'; import { StyleSheet, useWindowDimensions } from 'react-native'; @@ -7,6 +7,7 @@ import NativeClerkUserButtonView from '../specs/NativeClerkUserButtonView'; import { isNativeSupported } from '../utils/native-module'; import type { NativeUserProfileNavigationHandle, + UserProfileCustomDestination, UserProfileCustomPage, UserProfileCustomPageEvent, } from './UserProfileCustomPages'; @@ -28,6 +29,9 @@ const CustomizableNativeClerkUserButtonView = export interface UserButtonUserProfileProps { /** Custom pages displayed as rows in the user profile. */ customPages?: UserProfileCustomPage[]; + + /** Custom destinations that can be pushed from a custom page without creating a profile row. */ + customDestinations?: UserProfileCustomDestination[]; } export interface UserButtonProps { @@ -61,8 +65,13 @@ export interface UserButtonProps { */ export function UserButton({ userProfileProps }: UserButtonProps): JSX.Element | null { const nativeViewRef = useRef(null); - const customPages = userProfileProps?.customPages ?? []; - const { activePath, onCustomPageEvent } = useUserProfileCustomPages(customPages, nativeViewRef); + const customPages = userProfileProps?.customPages; + const customDestinations = userProfileProps?.customDestinations; + const customRoutes = useMemo( + () => [...(customPages ?? []), ...(customDestinations ?? [])], + [customDestinations, customPages], + ); + const { navigation, presentedPaths, onCustomPageEvent } = useUserProfileCustomPages(customRoutes, nativeViewRef); const { width, height } = useWindowDimensions(); if (!isNativeSupported || !CustomizableNativeClerkUserButtonView) { @@ -73,13 +82,13 @@ export function UserButton({ userProfileProps }: UserButtonProps): JSX.Element | diff --git a/packages/expo/src/native/UserProfileCustomPages.tsx b/packages/expo/src/native/UserProfileCustomPages.tsx index a990971cc80..93d2f0bf706 100644 --- a/packages/expo/src/native/UserProfileCustomPages.tsx +++ b/packages/expo/src/native/UserProfileCustomPages.tsx @@ -32,13 +32,15 @@ export type UserProfileCustomPagePlacement = | { type: 'before'; row: UserProfileRow } | { type: 'after'; row: UserProfileRow }; -interface UserProfileCustomPageBase { +interface UserProfileCustomDestinationBase { /** Unique path used to identify and navigate to the page. */ path: string; - /** Text displayed in the native user profile row. */ + /** Text displayed in the native navigation title and, for root pages, the profile row. */ label: string; +} +interface UserProfileCustomPageBase extends UserProfileCustomDestinationBase { /** Icon displayed in the native user profile row. */ icon?: UserProfileCustomPageIcon; @@ -61,6 +63,14 @@ export type UserProfileCustomPage = UserProfileCustomPageBase & } ); +/** A custom destination reached by pushing from another custom user profile page. */ +export type UserProfileCustomDestination = UserProfileCustomDestinationBase & { + /** React Native content rendered when the destination is pushed. */ + content: ReactNode; +}; + +type UserProfileCustomRoute = UserProfileCustomPage | UserProfileCustomDestination; + export type UserProfileCustomPageEvent = Readonly<{ type: 'presented' | 'dismissed'; path: string; @@ -74,7 +84,10 @@ export interface UserProfileCustomPageNavigation { /** Returns to the root user profile screen. */ popToRoot: () => Promise; - /** Pushes another custom page by path. */ + /** + * Pushes another registered custom page or custom destination by path. + * Rejects when that path is already active in the navigation stack. + */ push: (path: string) => Promise; } @@ -95,10 +108,13 @@ export function useUserProfileCustomPageNavigation(): UserProfileCustomPageNavig return navigation; } -export function serializeUserProfileCustomPages(customPages: UserProfileCustomPage[]): string { +export function serializeUserProfileCustomPages( + customPages: UserProfileCustomPage[], + customDestinations: UserProfileCustomDestination[] = [], +): string { const paths = new Set(); - for (const { path } of customPages) { + for (const { path } of [...customPages, ...customDestinations]) { if (paths.has(path)) { throw new Error(`User profile custom page path "${path}" must be unique.`); } @@ -106,37 +122,78 @@ export function serializeUserProfileCustomPages(customPages: UserProfileCustomPa paths.add(path); } - return JSON.stringify( - customPages.map(page => ({ + return JSON.stringify([ + ...customPages.map(page => ({ path: page.path, label: page.label, icon: page.icon ?? 'settings', placement: page.placement ?? { type: 'sectionEnd', section: 'profile' }, })), - ); + ...customDestinations.map(destination => ({ + path: destination.path, + label: destination.label, + icon: 'settings', + placement: { type: 'sectionEnd', section: 'profile' }, + showAsRow: false, + })), + ]); } export function useUserProfileCustomPages( - customPages: UserProfileCustomPage[], + customRoutes: UserProfileCustomRoute[], navigationHandleRef: RefObject, ) { - const [activePath, setActivePath] = useState(); + const [presentedPaths, setPresentedPaths] = useState>(() => new Set()); + const presentedPathStack = useRef([]); + const isPoppingToRoot = useRef(false); const openingPaths = useRef(new Set()); + const updatePresentedPaths = useCallback((update: (paths: string[]) => string[]) => { + const currentPaths = presentedPathStack.current; + const nextPaths = update(currentPaths); + + if (nextPaths === currentPaths) { + return; + } + + presentedPathStack.current = nextPaths; + setPresentedPaths(new Set(nextPaths)); + }, []); + const onCustomPageEvent = useCallback( (event: NativeSyntheticEvent) => { const { path, type } = event.nativeEvent; if (type === 'dismissed') { - setActivePath(currentPath => (currentPath === path ? undefined : currentPath)); + updatePresentedPaths(currentPaths => { + const pathIndex = currentPaths.lastIndexOf(path); + if (pathIndex === -1) { + return currentPaths; + } + + if (isPoppingToRoot.current) { + isPoppingToRoot.current = false; + return []; + } + + // Native navigation stops rendering pages that are covered by a push. Keep + // those pages mounted until they are actually removed from the back stack. + if (pathIndex !== currentPaths.length - 1) { + return currentPaths; + } + + return currentPaths.slice(0, pathIndex); + }); return; } - const page = customPages.find(candidate => candidate.path === path); + const page = customRoutes.find(candidate => candidate.path === path); if (!page) { return; } + updatePresentedPaths(currentPaths => (currentPaths.includes(path) ? currentPaths : [...currentPaths, path])); + if ('href' in page && page.href) { if (openingPaths.current.has(path)) { return; @@ -153,43 +210,78 @@ export function useUserProfileCustomPages( }); return; } - - setActivePath(path); }, - [customPages, navigationHandleRef], + [customRoutes, navigationHandleRef, updatePresentedPaths], + ); + + const navigation = useMemo( + () => ({ + navigateBack: () => navigationHandleRef.current?.navigateCustomPage('back') ?? Promise.resolve(), + popToRoot: () => { + const navigationHandle = navigationHandleRef.current; + if (!navigationHandle) { + return Promise.resolve(); + } + + isPoppingToRoot.current = presentedPathStack.current.length > 0; + return navigationHandle.navigateCustomPage('popToRoot').catch(error => { + isPoppingToRoot.current = false; + throw error; + }); + }, + push: path => { + const navigationHandle = navigationHandleRef.current; + if (!navigationHandle) { + return Promise.resolve(); + } + + if (!customRoutes.some(route => route.path === path)) { + return Promise.reject( + new Error(`No custom user profile page or destination is registered for path "${path}".`), + ); + } + + if (presentedPathStack.current.includes(path)) { + return Promise.reject( + new Error(`Custom user profile page or destination "${path}" is already in the navigation stack.`), + ); + } + + updatePresentedPaths(currentPaths => [...currentPaths, path]); + + return navigationHandle.navigateCustomPage('push', path).catch(error => { + updatePresentedPaths(currentPaths => + currentPaths[currentPaths.length - 1] === path ? currentPaths.slice(0, -1) : currentPaths, + ); + throw error; + }); + }, + }), + [customRoutes, navigationHandleRef, updatePresentedPaths], ); - return { activePath, onCustomPageEvent }; + return { navigation, presentedPaths, onCustomPageEvent }; } export function UserProfileCustomPageHosts({ - customPages, - activePath, - navigationHandleRef, + customRoutes, + presentedPaths, + navigation, style, }: { - customPages: UserProfileCustomPage[]; - activePath?: string; - navigationHandleRef: RefObject; + customRoutes: UserProfileCustomRoute[]; + presentedPaths: ReadonlySet; + navigation: UserProfileCustomPageNavigation; 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 => ( + return customRoutes.map(page => ( - {activePath === page.path && 'content' in page ? page.content : null} + {presentedPaths.has(page.path) && 'content' in page ? page.content : null} )); diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo/src/native/UserProfileView.tsx index dff4e86d9e6..3c39d955375 100644 --- a/packages/expo/src/native/UserProfileView.tsx +++ b/packages/expo/src/native/UserProfileView.tsx @@ -1,5 +1,5 @@ import type { ComponentProps, ComponentType, JSX, Ref } from 'react'; -import { useCallback, useRef } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import type { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native'; import { StyleSheet, Text, View } from 'react-native'; @@ -8,6 +8,7 @@ import { isNativeSupported } from '../utils/native-module'; import type { EmbeddedNavigationProps } from './EmbeddedNavigation.types'; import type { NativeUserProfileNavigationHandle, + UserProfileCustomDestination, UserProfileCustomPage, UserProfileCustomPageEvent, } from './UserProfileCustomPages'; @@ -52,6 +53,9 @@ export interface UserProfileViewProps extends EmbeddedNavigationProps { /** Custom pages displayed as rows in the root profile screen. */ customPages?: UserProfileCustomPage[]; + + /** Custom destinations that can be pushed from a custom page without creating a root profile row. */ + customDestinations?: UserProfileCustomDestination[]; } /** @@ -85,9 +89,12 @@ export interface UserProfileViewProps extends EmbeddedNavigationProps { * }, + * { path: 'billing', label: 'Billing', icon: 'billing', content: }, * { path: 'docs', label: 'Docs', icon: 'book', href: 'https://clerk.com/docs' }, * ]} + * customDestinations={[ + * { path: 'invoice-details', label: 'Invoice details', content: }, + * ]} * /> * ); * } @@ -101,9 +108,11 @@ export function UserProfileView({ onDismiss, onHostBack, customPages = [], + customDestinations = [], }: UserProfileViewProps): JSX.Element { const nativeViewRef = useRef(null); - const { activePath, onCustomPageEvent } = useUserProfileCustomPages(customPages, nativeViewRef); + const customRoutes = useMemo(() => [...customPages, ...customDestinations], [customDestinations, customPages]); + const { navigation, presentedPaths, onCustomPageEvent } = useUserProfileCustomPages(customRoutes, nativeViewRef); const handleProfileEvent = useCallback( (event: { nativeEvent: { type: string } }) => { if (event.nativeEvent.type === 'dismissed') { @@ -131,15 +140,15 @@ export function UserProfileView({ style={[styles.container, style]} isDismissible={isDismissible} hostBackButton={!!onHostBack} - customPages={serializeUserProfileCustomPages(customPages)} + customPages={serializeUserProfileCustomPages(customPages, customDestinations)} onProfileEvent={handleProfileEvent} onCustomPageEvent={onCustomPageEvent} onHostBack={onHostBack ? () => onHostBack() : undefined} > ); diff --git a/packages/expo/src/native/__tests__/UserButton.test.tsx b/packages/expo/src/native/__tests__/UserButton.test.tsx index 85822876a88..d4eaef80382 100644 --- a/packages/expo/src/native/__tests__/UserButton.test.tsx +++ b/packages/expo/src/native/__tests__/UserButton.test.tsx @@ -39,6 +39,13 @@ describe('UserButton', () => { content:
API keys page
, }, ], + customDestinations: [ + { + path: 'api-key-details', + label: 'API key details', + content:
API key details page
, + }, + ], }} />, ); @@ -51,6 +58,13 @@ describe('UserButton', () => { icon: 'key', placement: { type: 'sectionEnd', section: 'profile' }, }, + { + path: 'api-key-details', + label: 'API key details', + icon: 'settings', + placement: { type: 'sectionEnd', section: 'profile' }, + showAsRow: false, + }, ]); }); }); diff --git a/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx b/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx index c34c01eed86..58e29be93a3 100644 --- a/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx +++ b/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx @@ -50,6 +50,29 @@ describe('serializeUserProfileCustomPages', () => { expect(JSON.parse(result)[1].placement).toEqual({ type: 'before', row: 'signOut' }); }); + test('serializes push-only destinations without exposing profile rows', () => { + const result = serializeUserProfileCustomPages( + [{ path: 'billing', label: 'Billing', content: null }], + [{ path: 'invoice-details', label: 'Invoice details', content: null }], + ); + + expect(JSON.parse(result)).toEqual([ + { + path: 'billing', + label: 'Billing', + icon: 'settings', + placement: { type: 'sectionEnd', section: 'profile' }, + }, + { + path: 'invoice-details', + label: 'Invoice details', + icon: 'settings', + placement: { type: 'sectionEnd', section: 'profile' }, + showAsRow: false, + }, + ]); + }); + test('rejects duplicate page paths', () => { expect(() => serializeUserProfileCustomPages([ @@ -58,4 +81,13 @@ describe('serializeUserProfileCustomPages', () => { ]), ).toThrow('User profile custom page path "billing" must be unique.'); }); + + test('rejects duplicate paths across pages and destinations', () => { + expect(() => + serializeUserProfileCustomPages( + [{ path: 'billing', label: 'Billing', content: null }], + [{ path: 'billing', label: 'Billing details', 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 8e7bd6a5f1f..34af7275e6d 100644 --- a/packages/expo/src/native/__tests__/UserProfileView.test.tsx +++ b/packages/expo/src/native/__tests__/UserProfileView.test.tsx @@ -1,7 +1,9 @@ -import { act, render, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { UserProfileCustomPageNavigation } from '../UserProfileCustomPages'; +import { useUserProfileCustomPageNavigation } from '../UserProfileCustomPages'; import { UserProfileView } from '../UserProfileView'; const mocks = vi.hoisted(() => { @@ -41,11 +43,55 @@ function lastNativeProps() { return mocks.nativeProps.mock.calls.at(-1)?.[0]; } +function BillingPage() { + const { push } = useUserProfileCustomPageNavigation(); + + return ( + + ); +} + +function InvoiceDetailsPage() { + const { popToRoot } = useUserProfileCustomPageNavigation(); + + return ( + + ); +} + +let latestNavigation: UserProfileCustomPageNavigation | null = null; + +function NavigationCapture() { + latestNavigation = useUserProfileCustomPageNavigation(); + return null; +} + +function getLatestNavigation(): UserProfileCustomPageNavigation { + if (!latestNavigation) { + throw new Error('Expected custom page navigation to be available.'); + } + + return latestNavigation; +} + describe('UserProfileView', () => { + afterEach(cleanup); + beforeEach(() => { mocks.navigateCustomPage.mockClear(); mocks.nativeProps.mockClear(); mocks.openURL.mockClear(); + latestNavigation = null; }); test('calls onDismiss when the native profile view emits dismissed', () => { @@ -121,6 +167,90 @@ describe('UserProfileView', () => { expect(result.queryByText('API keys page')).toBeNull(); }); + test('pushes a destination that is not exposed as a profile row', () => { + const result = render( + }]} + customDestinations={[ + { path: 'invoice-details', label: 'Invoice details', content:
Invoice details page
}, + ]} + />, + ); + + expect(JSON.parse(lastNativeProps().customPages)[1]).toMatchObject({ + path: 'invoice-details', + showAsRow: false, + }); + + act(() => { + lastNativeProps().onCustomPageEvent({ nativeEvent: { type: 'presented', path: 'billing' } }); + }); + fireEvent.click(result.getByText('View invoice')); + expect(mocks.navigateCustomPage).toHaveBeenCalledWith('push', 'invoice-details'); + expect(result.getByText('Invoice details page')).toBeDefined(); + + act(() => { + lastNativeProps().onCustomPageEvent({ nativeEvent: { type: 'dismissed', path: 'billing' } }); + }); + expect(result.getByText('View invoice')).toBeDefined(); + + act(() => { + lastNativeProps().onCustomPageEvent({ nativeEvent: { type: 'presented', path: 'invoice-details' } }); + }); + expect(result.getByText('Invoice details page')).toBeDefined(); + expect(result.getByText('View invoice')).toBeDefined(); + + act(() => { + lastNativeProps().onCustomPageEvent({ nativeEvent: { type: 'dismissed', path: 'invoice-details' } }); + }); + expect(result.queryByText('Invoice details page')).toBeNull(); + expect(result.getByText('View invoice')).toBeDefined(); + }); + + test('rejects pushing a path that is already in the active stack', async () => { + render( + }]} + customDestinations={[{ path: 'invoice-details', label: 'Invoice details', content: }]} + />, + ); + act(() => { + lastNativeProps().onCustomPageEvent({ nativeEvent: { type: 'presented', path: 'billing' } }); + }); + + await act(async () => { + await getLatestNavigation().push('invoice-details'); + }); + + await expect(getLatestNavigation().push('billing')).rejects.toThrow( + 'Custom user profile page or destination "billing" is already in the navigation stack.', + ); + expect(mocks.navigateCustomPage).toHaveBeenCalledTimes(1); + expect(mocks.navigateCustomPage).toHaveBeenCalledWith('push', 'invoice-details'); + }); + + test('unmounts the retained custom page stack after returning to the profile root', () => { + const result = render( + }]} + customDestinations={[{ path: 'invoice-details', label: 'Invoice details', content: }]} + />, + ); + + act(() => { + lastNativeProps().onCustomPageEvent({ nativeEvent: { type: 'presented', path: 'billing' } }); + }); + fireEvent.click(result.getByText('View invoice')); + fireEvent.click(result.getByText('Done')); + expect(mocks.navigateCustomPage).toHaveBeenLastCalledWith('popToRoot'); + + act(() => { + lastNativeProps().onCustomPageEvent({ nativeEvent: { type: 'dismissed', path: 'invoice-details' } }); + }); + expect(result.queryByText('View invoice')).toBeNull(); + expect(result.queryByText('Done')).toBeNull(); + }); + test('opens href pages externally and returns to the profile root', async () => { render(); diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts index d19d5c8b298..4b97285c02a 100644 --- a/packages/expo/src/native/index.ts +++ b/packages/expo/src/native/index.ts @@ -36,6 +36,7 @@ export type { UserButtonProps, UserButtonUserProfileProps } from './UserButton'; export { useUserProfileCustomPageNavigation } from './UserProfileCustomPages'; export type { UserProfileCustomPageNavigation, + UserProfileCustomDestination, UserProfileCustomPage, UserProfileCustomPageIcon, UserProfileCustomPagePlacement,