Skip to content
Open
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/fuzzy-lions-recompose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Fix Android native UI components becoming unresponsive after their views detach and reattach during navigation.
Original file line number Diff line number Diff line change
Expand Up @@ -31,27 +31,41 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte
view.setViewTreeLifecycleOwner(act)
view.setViewTreeViewModelStoreOwner(act)
view.setViewTreeSavedStateRegistryOwner(act)

val recomposerContext = AndroidUiDispatcher.Main
val newRecomposer = Recomposer(recomposerContext)
recomposer = newRecomposer
view.setParentCompositionContext(newRecomposer)
val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob())
recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job]
scope.launch {
newRecomposer.runRecomposeAndApplyChanges()
}
}
addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
}

override fun onAttachedToWindow() {
super.onAttachedToWindow()
startRecomposer()
}

override fun onDetachedFromWindow() {
composeView.disposeComposition()
recomposer?.cancel()
recomposerJob?.cancel()
recomposer = null
recomposerJob = null
onHostDetachedFromWindow()
super.onDetachedFromWindow()
}

private fun startRecomposer() {
if (activity == null || recomposerJob?.isActive == true) return

// Navigation can temporarily detach and later reattach the same native host.
// Always give a reattached ComposeView a live parent composition context.
val recomposerContext = AndroidUiDispatcher.Main
val newRecomposer = Recomposer(recomposerContext)
recomposer = newRecomposer
composeView.setParentCompositionContext(newRecomposer)
val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob())
recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job]
scope.launch {
newRecomposer.runRecomposeAndApplyChanges()
}
}
Comment on lines +53 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

recomposerJob tracks the wrong job — isActive guard can silently skip recovery.

recomposerJob is set to scope.coroutineContext[Job], which is the root SupervisorJob(), not the coroutine launched at Line 64-66. A SupervisorJob() created this way stays isActive == true even after its child coroutine (runRecomposeAndApplyChanges()) completes or throws, because supervisor semantics isolate child failures from the parent and the parent job doesn't auto-complete when its lone child finishes.

Concretely: if the recomposition coroutine ever fails or exits, recomposerJob?.isActive will still report true on the next onAttachedToWindow(), so Line 54's guard causes startRecomposer() to skip creating a fresh recomposer — reintroducing the exact "stale, non-functional recomposer" symptom this PR is fixing, just triggered by a recomposition failure instead of a cancel/detach.

Track the actually-launched job instead:

🐛 Proposed fix
     val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob())
-    recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job]
-    scope.launch {
+    recomposerJob = scope.launch {
       newRecomposer.runRecomposeAndApplyChanges()
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private fun startRecomposer() {
if (activity == null || recomposerJob?.isActive == true) return
// Navigation can temporarily detach and later reattach the same native host.
// Always give a reattached ComposeView a live parent composition context.
val recomposerContext = AndroidUiDispatcher.Main
val newRecomposer = Recomposer(recomposerContext)
recomposer = newRecomposer
composeView.setParentCompositionContext(newRecomposer)
val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob())
recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job]
scope.launch {
newRecomposer.runRecomposeAndApplyChanges()
}
}
private fun startRecomposer() {
if (activity == null || recomposerJob?.isActive == true) return
// Navigation can temporarily detach and later reattach the same native host.
// Always give a reattached ComposeView a live parent composition context.
val recomposerContext = AndroidUiDispatcher.Main
val newRecomposer = Recomposer(recomposerContext)
recomposer = newRecomposer
composeView.setParentCompositionContext(newRecomposer)
val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob())
recomposerJob = scope.launch {
newRecomposer.runRecomposeAndApplyChanges()
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt`
around lines 53 - 67, Update startRecomposer so recomposerJob references the Job
returned by the coroutine launched to run
newRecomposer.runRecomposeAndApplyChanges(), rather than the standalone
SupervisorJob in the scope context. Preserve the existing scope and recomposer
setup, while ensuring the isActive guard permits recovery after the
recomposition coroutine completes or fails.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all subclasses of ClerkComposeNativeViewHost and check their onAttachedToWindow overrides
rg -n --type=kt 'ClerkComposeNativeViewHost' -g '*.kt'
ast-grep run --pattern 'override fun onAttachedToWindow() {
  $$$
}' --lang kotlin packages/expo/android/src/main/java/expo/modules/clerk

Repository: clerk/javascript

Length of output: 1065


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files and search for subclasses/reattach patterns.
git ls-files 'packages/expo/android/src/main/java/expo/modules/clerk/*.kt'

echo
echo '--- base class ---'
sed -n '1,220p' packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt

echo
echo '--- user button module ---'
sed -n '1,220p' packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt

echo
echo '--- subclass search ---'
rg -n 'class\s+\w+\s*:\s*ClerkComposeNativeViewHost|:.*ClerkComposeNativeViewHost' packages/expo/android/src/main/java/expo/modules/clerk

echo
echo '--- setupView search ---'
rg -n 'setupView\(\)|onAttachedToWindow\(\)' packages/expo/android/src/main/java/expo/modules/clerk

Repository: clerk/javascript

Length of output: 6981


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for any other references to the base host and its attach/setup behavior.
rg -n --glob '*.kt' 'ClerkComposeNativeViewHost|setupView\(\)|onAttachedToWindow\(\)' packages/expo/android/src/main/java

Repository: clerk/javascript

Length of output: 1908


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- ClerkAuthViewModule.kt ---'
sed -n '1,180p' packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt

echo
echo '--- ClerkUserProfileViewModule.kt ---'
sed -n '1,180p' packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt

Repository: clerk/javascript

Length of output: 5821


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the exact lifecycle hooks for the remaining subclasses.
ast-grep outline packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt --view expanded
echo
ast-grep outline packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt --view expanded

Repository: clerk/javascript

Length of output: 1803


Re-run setupView() on reattach for the other native views. ClerkAuthNativeView and ClerkUserProfileNativeView still only call setupView() from OnViewDidUpdateProps; only ClerkUserButtonNativeView does it in onAttachedToWindow(). If either view is detached and reattached without a prop change, the recomposer restarts but the Compose content does not.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt`
around lines 53 - 67, Update ClerkAuthNativeView and ClerkUserProfileNativeView
so onAttachedToWindow() invokes setupView(), matching ClerkUserButtonNativeView.
Preserve the existing OnViewDidUpdateProps behavior and ensure reattaching
without prop changes recreates the Compose content after the recomposer
restarts.


fun setupView() {
composeView.setContent {
val viewModelStoreOwner = localViewModelStoreOwner()
Expand Down