Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-tabs-remember.md
Original file line numberDiff line numberDiff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/tidy-destinations-push.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,8 @@ try {
}

dependencies {
testImplementation "junit:junit:4.13.2"

implementation project(':expo-modules-core')

// Coroutines for async operations
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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)
Expand All@@ -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

Expand All@@ -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 {
Expand All@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -22,17 +23,24 @@ 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<View>()
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) }
}

@Composable
override fun Content() {
val user by Clerk.userFlow.collectAsStateWithLifecycle()

LaunchedEffect(user?.id) { customPageState.userDidChange(user?.id) }

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
Expand DownExpand Up@@ -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(
Expand All@@ -94,12 +117,10 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
private fun customRows(): List<UserProfileCustomRow> = 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<ClerkUserProfileCustomPageConfig> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
}

class ClerkUserButtonViewModule : Module() {
Expand All@@ -118,7 +139,7 @@ class ClerkUserButtonViewModule : Module() {
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
view.setCustomPages(customPages)
}

AsyncFunction("navigateCustomPage") {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<String>()
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<String>) {
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<String> = 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) }
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-tabs-remember.md
Original file line numberDiff line numberDiff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/tidy-destinations-push.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,8 @@ try {
}

dependencies {
testImplementation "junit:junit:4.13.2"

implementation project(':expo-modules-core')

// Coroutines for async operations
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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)
Expand All@@ -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

Expand All@@ -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 {
Expand All@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -22,17 +23,24 @@ 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<View>()
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) }
}

@Composable
override fun Content() {
val user by Clerk.userFlow.collectAsStateWithLifecycle()

LaunchedEffect(user?.id) { customPageState.userDidChange(user?.id) }

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
Expand DownExpand Up@@ -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(
Expand All@@ -94,12 +117,10 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
private fun customRows(): List<UserProfileCustomRow> = 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<ClerkUserProfileCustomPageConfig> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
}

class ClerkUserButtonViewModule : Module() {
Expand All@@ -118,7 +139,7 @@ class ClerkUserButtonViewModule : Module() {
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
view.setCustomPages(customPages)
}

AsyncFunction("navigateCustomPage") {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<String>()
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<String>) {
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<String> = 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) }
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-tabs-remember.md
Original file line numberDiff line numberDiff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/tidy-destinations-push.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,8 @@ try {
}

dependencies {
testImplementation "junit:junit:4.13.2"

implementation project(':expo-modules-core')

// Coroutines for async operations
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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)
Expand All@@ -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

Expand All@@ -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 {
Expand All@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -22,17 +23,24 @@ 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<View>()
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) }
}

@Composable
override fun Content() {
val user by Clerk.userFlow.collectAsStateWithLifecycle()

LaunchedEffect(user?.id) { customPageState.userDidChange(user?.id) }

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
Expand DownExpand Up@@ -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(
Expand All@@ -94,12 +117,10 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
private fun customRows(): List<UserProfileCustomRow> = 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<ClerkUserProfileCustomPageConfig> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
}

class ClerkUserButtonViewModule : Module() {
Expand All@@ -118,7 +139,7 @@ class ClerkUserButtonViewModule : Module() {
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
view.setCustomPages(customPages)
}

AsyncFunction("navigateCustomPage") {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<String>()
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<String>) {
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<String> = 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) }
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-tabs-remember.md
Original file line numberDiff line numberDiff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/tidy-destinations-push.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,8 @@ try {
}

dependencies {
testImplementation "junit:junit:4.13.2"

implementation project(':expo-modules-core')

// Coroutines for async operations
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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)
Expand All@@ -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

Expand All@@ -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 {
Expand All@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -22,17 +23,24 @@ 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<View>()
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) }
}

@Composable
override fun Content() {
val user by Clerk.userFlow.collectAsStateWithLifecycle()

LaunchedEffect(user?.id) { customPageState.userDidChange(user?.id) }

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
Expand DownExpand Up@@ -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(
Expand All@@ -94,12 +117,10 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
private fun customRows(): List<UserProfileCustomRow> = 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<ClerkUserProfileCustomPageConfig> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
}

class ClerkUserButtonViewModule : Module() {
Expand All@@ -118,7 +139,7 @@ class ClerkUserButtonViewModule : Module() {
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
view.setCustomPages(customPages)
}

AsyncFunction("navigateCustomPage") {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<String>()
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<String>) {
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<String> = 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) }
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-tabs-remember.md
Original file line numberDiff line numberDiff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/tidy-destinations-push.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,8 @@ try {
}

dependencies {
testImplementation "junit:junit:4.13.2"

implementation project(':expo-modules-core')

// Coroutines for async operations
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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)
Expand All@@ -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

Expand All@@ -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 {
Expand All@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -22,17 +23,24 @@ 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<View>()
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) }
}

@Composable
override fun Content() {
val user by Clerk.userFlow.collectAsStateWithLifecycle()

LaunchedEffect(user?.id) { customPageState.userDidChange(user?.id) }

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
Expand DownExpand Up@@ -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(
Expand All@@ -94,12 +117,10 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
private fun customRows(): List<UserProfileCustomRow> = 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<ClerkUserProfileCustomPageConfig> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
}

class ClerkUserButtonViewModule : Module() {
Expand All@@ -118,7 +139,7 @@ class ClerkUserButtonViewModule : Module() {
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
view.setCustomPages(customPages)
}

AsyncFunction("navigateCustomPage") {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<String>()
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<String>) {
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<String> = 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) }
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-tabs-remember.md
Original file line numberDiff line numberDiff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/tidy-destinations-push.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,8 @@ try {
}

dependencies {
testImplementation "junit:junit:4.13.2"

implementation project(':expo-modules-core')

// Coroutines for async operations
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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)
Expand All@@ -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

Expand All@@ -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 {
Expand All@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -22,17 +23,24 @@ 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<View>()
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) }
}

@Composable
override fun Content() {
val user by Clerk.userFlow.collectAsStateWithLifecycle()

LaunchedEffect(user?.id) { customPageState.userDidChange(user?.id) }

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
Expand DownExpand Up@@ -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(
Expand All@@ -94,12 +117,10 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
private fun customRows(): List<UserProfileCustomRow> = 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<ClerkUserProfileCustomPageConfig> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
}

class ClerkUserButtonViewModule : Module() {
Expand All@@ -118,7 +139,7 @@ class ClerkUserButtonViewModule : Module() {
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
view.setCustomPages(customPages)
}

AsyncFunction("navigateCustomPage") {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<String>()
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<String>) {
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<String> = 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) }
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-tabs-remember.md
Original file line numberDiff line numberDiff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/tidy-destinations-push.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,8 @@ try {
}

dependencies {
testImplementation "junit:junit:4.13.2"

implementation project(':expo-modules-core')

// Coroutines for async operations
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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)
Expand All@@ -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

Expand All@@ -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 {
Expand All@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -22,17 +23,24 @@ 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<View>()
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) }
}

@Composable
override fun Content() {
val user by Clerk.userFlow.collectAsStateWithLifecycle()

LaunchedEffect(user?.id) { customPageState.userDidChange(user?.id) }

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
Expand DownExpand Up@@ -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(
Expand All@@ -94,12 +117,10 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
private fun customRows(): List<UserProfileCustomRow> = 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<ClerkUserProfileCustomPageConfig> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
}

class ClerkUserButtonViewModule : Module() {
Expand All@@ -118,7 +139,7 @@ class ClerkUserButtonViewModule : Module() {
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
view.setCustomPages(customPages)
}

AsyncFunction("navigateCustomPage") {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<String>()
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<String>) {
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<String> = 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) }
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-tabs-remember.md
Original file line numberDiff line numberDiff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/tidy-destinations-push.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/expo/android/build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,8 @@ try {
}

dependencies {
testImplementation "junit:junit:4.13.2"

implementation project(':expo-modules-core')

// Coroutines for async operations
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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)
Expand All@@ -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

Expand All@@ -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 {
Expand All@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -22,17 +23,24 @@ 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<View>()
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) }
}

@Composable
override fun Content() {
val user by Clerk.userFlow.collectAsStateWithLifecycle()

LaunchedEffect(user?.id) { customPageState.userDidChange(user?.id) }

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
Expand DownExpand Up@@ -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(
Expand All@@ -94,12 +117,10 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
private fun customRows(): List<UserProfileCustomRow> = 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<ClerkUserProfileCustomPageConfig> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
}

class ClerkUserButtonViewModule : Module() {
Expand All@@ -118,7 +139,7 @@ class ClerkUserButtonViewModule : Module() {
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
view.setCustomPages(customPages)
}

AsyncFunction("navigateCustomPage") {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<String>()
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<String>) {
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<String> = 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) }
}
}
Loading
Loading