fix: embed surface no longer blacks out on double-tap / first open

Two black-surface reports traced to the active-tab bookkeeping:

- Double-tapping a bottom-bar tab pops and re-adds the SAME route, so the
  outgoing screen disposes AFTER the incoming one has already called
  setActive(id). clearActiveIfMatches(id) then nulled the active id the new
  instance just set (same id "matches"), leaving no active tab — every warm
  surface gets shoved off-screen and the embed goes black. setActive now returns
  a monotonic ownership token and the disposer clears only if it's still the
  latest claim (clearActiveIfOwner), so a re-nav can't null the new owner.
  clearActiveChrome got the same twin guard.
- Revert the reportBounds active-id guard added in the audit batch: it tied
  contentBounds to the same fragile activeId, so a first-ever embed whose bounds
  reported while the id was momentarily unset stayed at Rect.Zero (parked
  off-screen → black). Bounds are reported unconditionally again; the two
  screens cover the same content area, so there's nothing to clobber.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
This commit is contained in:
Claude
2026-06-24 01:10:18 +00:00
parent a0ad9082e5
commit 984c4e4e8c
3 changed files with 26 additions and 16 deletions
@@ -144,9 +144,9 @@ private fun EmbeddedFavoriteTab(
val bottomBarFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems
DisposableEffect(id) {
EmbeddedTabHost.setActive(id)
val token = EmbeddedTabHost.setActive(id)
onDispose {
EmbeddedTabHost.clearActiveIfMatches(id)
EmbeddedTabHost.clearActiveIfOwner(token)
EmbeddedTabHost.clearActiveChrome(id)
// Only bottom-row apps stay warm; anything else restarts when it leaves.
if (id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id)
@@ -165,7 +165,7 @@ private fun EmbeddedFavoriteTab(
Modifier
.fillMaxSize()
.padding(padding)
.onGloballyPositioned { EmbeddedTabHost.reportBounds(id, it.boundsInWindow()) },
.onGloballyPositioned { EmbeddedTabHost.reportBounds(it.boundsInWindow()) },
)
}
}
@@ -82,7 +82,10 @@ object EmbeddedTabHost {
}
fun clearActiveChrome(id: String) {
if (chromeOwner == id) {
// Same twin race as the active id: on a same-route re-nav the new instance has already published
// its chrome (same id) before the old one disposes. Only clear when this id is no longer the
// active tab, so the incoming tab's chrome survives.
if (chromeOwner == id && activeId != id) {
chromeOwner = null
activeChrome = null
}
@@ -99,20 +102,27 @@ object EmbeddedTabHost {
return controller
}
fun setActive(id: String) {
// Monotonic ownership token. Double-tapping a bottom-bar tab pops and re-adds the SAME route, so the
// outgoing screen instance disposes *after* the incoming one has already called setActive with the
// same id. A plain `clearActiveIfMatches(id)` would then null the active id the new instance just set
// (same id → it "matches"), leaving no active tab and blacking the surface out. Tokening each
// setActive lets the disposer clear only if nobody claimed active in the meantime.
private var activeToken = 0L
/** Marks [id] the active tab and returns the ownership token to hand back to [clearActiveIfOwner]. */
fun setActive(id: String): Long {
activeId = id
activeToken += 1
return activeToken
}
fun clearActiveIfMatches(id: String) {
if (activeId == id) activeId = null
/** Clears the active tab only if [token] is still the latest claim (no newer [setActive] ran). */
fun clearActiveIfOwner(token: Long) {
if (activeToken == token) activeId = null
}
fun reportBounds(
id: String,
bounds: Rect,
) {
// Only the active tab positions the surface; a cross-fading outgoing screen must not move it.
if (activeId == id) contentBounds = bounds
fun reportBounds(bounds: Rect) {
contentBounds = bounds
}
fun evict(id: String) {
@@ -158,9 +158,9 @@ private fun EmbeddedNappletTab(
val bottomBarFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems
DisposableEffect(id) {
EmbeddedTabHost.setActive(id)
val token = EmbeddedTabHost.setActive(id)
onDispose {
EmbeddedTabHost.clearActiveIfMatches(id)
EmbeddedTabHost.clearActiveIfOwner(token)
EmbeddedTabHost.clearActiveChrome(id)
// Only bottom-row apps stay warm; anything else restarts when it leaves.
if (id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id)
@@ -200,7 +200,7 @@ private fun EmbeddedNappletTab(
Modifier
.fillMaxSize()
.padding(padding)
.onGloballyPositioned { EmbeddedTabHost.reportBounds(id, it.boundsInWindow()) },
.onGloballyPositioned { EmbeddedTabHost.reportBounds(it.boundsInWindow()) },
)
}
}