From 984c4e4e8c7e17c2ff9f35c5e4085699f0bf1698 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 01:10:18 +0000 Subject: [PATCH] fix: embed surface no longer blacks out on double-tap / first open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN --- .../loggedIn/browser/FavoriteWebAppScreen.kt | 6 ++-- .../screen/loggedIn/embed/EmbeddedTabHost.kt | 30 ++++++++++++------- .../favorites/FavoriteNappletScreen.kt | 6 ++-- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt index 1023496879..d1ed18dc82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt @@ -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()) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt index 1ef614352e..ffb27b4d8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt index 9cd376cc0c..393bc42f4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt @@ -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()) }, ) } }