fix(ui): teleported nav must not outlive the screen that raised it
Demo images / Build & push demo images (push) Successful in 3m30s
Demo images / Build & push demo images (push) Successful in 3m30s
Reported: the nav above the bottom bar — back buttons, the mesh tabs —
stayed stuck across other screens.
Cause is the KeepAlive work from phase 2, and specifically the half of it
that is invisible from the view's own file. Main tabs are KeepAlive'd, so
navigating DEACTIVATES a view instead of unmounting it. Content the view
Teleports to <body> is not in the view's DOM subtree, so deactivation
does not remove it and it keeps rendering over the destination screen.
Two offenders, matching the report exactly:
- Mesh.vue teleports its mobile TAB BAR and its chat BACK BUTTON to
<body>, gated only on `mobileShowChat` — never on whether Mesh was the
screen you were looking at.
- components/BackButton.vue teleports the shared mobile back button with
NO gate at all, so it leaked out of every view that uses it. Fixing the
shared component fixes every caller at once: Vue propagates
activated/deactivated from the KeepAlive boundary down through the
subtree, so a child can guard itself.
BaseModal already solved the transient-dialog half of this class in
204d4523 by closing on route change. That is the right fix for a dialog
and the wrong one for chrome: a tab bar has no "closed" state to fall
back to, and forcing one would lose the user's place. New
useViewActive() composable instead — chrome is simply not rendered while
its owner is off screen, and returns exactly as it was.
THE PERFORMANCE IS NOT SACRIFICED, which was the explicit constraint.
The Teleport is gated, not the view, so the instance stays cached and
revisiting a tab is still instant. A test pins this: setup() must run
exactly ONCE across a navigate-away-and-back round trip. If someone
later "fixes" this by dropping KeepAlive, that test fails.
Deliberately untouched: AppSession.vue, whose teleport is load-bearing —
its own comment records that moving the iframe node reloads the app, and
app-session is excluded from KeepAlive anyway so it cannot leak. Toasts,
the app launcher and the connection banner are app-level rather than
view-owned; gating those would be wrong.
Verified: 3 new tests; full suite 105 files / 848 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b945738d62
commit
a5b923fa0b
@@ -0,0 +1,41 @@
|
||||
import { ref, onActivated, onDeactivated, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Is this view the one currently on screen?
|
||||
*
|
||||
* Main tab views are `KeepAlive`'d (see `keepAliveRoutes.ts`), which is what
|
||||
* makes revisiting a tab instant — navigating away DEACTIVATES the view rather
|
||||
* than unmounting it. That is exactly the behaviour we want for performance, and
|
||||
* exactly the trap for anything the view `Teleport`s to `<body>`: teleported
|
||||
* content is not inside the view's own DOM subtree, so it keeps rendering over
|
||||
* whatever screen the user went to next. Mesh's mobile tab bar and its chat
|
||||
* back button did this — they stayed pinned above the bottom bar on every other
|
||||
* screen.
|
||||
*
|
||||
* `BaseModal` solves the transient-dialog half of this by closing itself on any
|
||||
* route change. That is the right fix for a dialog and the wrong one for
|
||||
* persistent chrome: a tab bar has no "closed" state to fall back to, and
|
||||
* forcing one would lose the user's place. Chrome should simply not be rendered
|
||||
* while its owner is off screen, and should come back exactly as it was.
|
||||
*
|
||||
* Usage — gate the `Teleport` itself, so nothing reaches `<body>` at all:
|
||||
*
|
||||
* const isViewActive = useViewActive()
|
||||
* <Teleport v-if="isViewActive" to="body"> … </Teleport>
|
||||
*
|
||||
* Defaults to `true`: outside a `KeepAlive` boundary neither hook ever fires, so
|
||||
* a view used both ways (or a component mounted bare in a test) must render
|
||||
* normally rather than stay invisible forever. Inside `KeepAlive`, Vue fires
|
||||
* `onActivated` immediately after `onMounted`, so the initial `true` is correct
|
||||
* there too and there is no first-paint gap.
|
||||
*/
|
||||
export function useViewActive(): Ref<boolean> {
|
||||
const isViewActive = ref(true)
|
||||
onActivated(() => {
|
||||
isViewActive.value = true
|
||||
})
|
||||
onDeactivated(() => {
|
||||
isViewActive.value = false
|
||||
})
|
||||
return isViewActive
|
||||
}
|
||||
Reference in New Issue
Block a user