archy/neode-ui/src/views/dashboard/DashboardRouterView.vue
archipelago 385c9d866e
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m48s
feat(02-02): tracer tab survives KeepAlive round-trip with revalidation
Wires the phase's shared architecture end to end through one main tab
(Marketplace, the worst-measured revisit per 02-FINDINGS.md):

- keepAliveRoutes.ts: exact-match route classifier (shouldKeepAlive,
  KEEP_ALIVE_PATHS, KEEP_ALIVE_MAX=6), seeded with only the tracer tab's
  path. Deliberately not KeepAlive `include` name-matching (async
  components under `<script setup>` have no inferable name).
- DashboardRouterView.vue: extracts Dashboard.vue's nested RouterView into
  a host where <KeepAlive> is a permanent element (never torn down by
  v-if) with its child conditionally present via shouldKeepAlive(route);
  a sibling Transition renders non-cached routes. Both original wrapper
  shapes (full-bleed chat/mesh vs. padded/scrollable default) are
  preserved via computed helpers on one stable, unkeyed wrapper div; the
  :key moves onto <component> itself. Adds per-route scroll retention
  since the scroll container is now stable across navigations.
- useCachedResource.ts: registers onActivated(() => refreshIfStale())
  alongside the existing onScopeDispose block, closing the gap where a
  kept-alive tab would otherwise never revalidate on reactivation
  (onScopeDispose doesn't fire on deactivate; window focus doesn't fire
  on an in-SPA tab switch). No-ops safely for all 8 existing consumers
  outside a KeepAlive boundary.
- useRouteTransitions.ts: exports TAB_ORDER so 02-04 can widen
  KEEP_ALIVE_PATHS from the same source of truth.
- Dashboard.vue: renders DashboardRouterView in place of the inline
  block; removes the now-superseded detail-route scroll save/restore
  (querySelector target no longer exists post-restructure — the new
  per-route Map in DashboardRouterView.vue is a strict superset).

Tests: keepAliveTabs.test.ts proves an included path's instance survives
a round trip (1 mount, 2 activations) while a detail path remounts (2
mounts); useCachedResource.test.ts proves no refetch inside the TTL,
exactly one refetch after it lapses, safe use outside KeepAlive, and
keep-last-value + sticky-ready semantics on a rejected refresh.

Full suite (706 tests), type-check, and build all green; built bundle
carries the new KeepAlive wiring (web/dist/neode-ui/assets).
2026-07-30 07:15:17 -04:00

127 lines
5.1 KiB
Vue

<template>
<RouterView v-slot="{ Component, route }">
<div
ref="wrapperRef"
class="mobile-safe-top"
:class="wrapperClass(route)"
:style="wrapperStyle"
>
<!-- KeepAlive itself is never conditionally removed — only its child
is, via v-if on the <component> below. Toggling KeepAlive itself
with v-if would destroy its whole instance cache on every
navigation away from a kept-alive route, which defeats the point. -->
<Transition :name="getTransitionName(route)">
<KeepAlive :max="KEEP_ALIVE_MAX">
<component
v-if="shouldKeepAlive(route)"
:is="Component"
:key="route.path"
:class="componentClass(route)"
/>
</KeepAlive>
</Transition>
<Transition :name="getTransitionName(route)">
<component
v-if="!shouldKeepAlive(route)"
:is="Component"
:key="route.path"
:class="componentClass(route)"
/>
</Transition>
<div v-if="!isFullBleedRoute(route)" class="shrink-0 h-6 md:h-12" aria-hidden="true"></div>
</div>
</RouterView>
</template>
<script setup lang="ts">
// The KeepAlive host extracted from Dashboard.vue's nested RouterView (D-01).
//
// Structural invariants (see 02-02-PLAN.md Task 1 for the full rationale —
// the tests in __tests__/keepAliveTabs.test.ts pin these):
// 1. Nothing between the RouterView slot and <KeepAlive> carries a binding
// that changes identity per route — the outer wrapper div below is
// stable (no :key); only its *classes* are route-derived. A keyed
// ancestor would tear down KeepAlive's entire instance cache on every
// navigation.
// 2. Composition order is <Transition> outside <KeepAlive> outside
// <component :is="Component">.
// 3. :key="route.path" lives on <component :is> itself, never on an
// ancestor of <KeepAlive> — putting it on a wrapping div would make
// KeepAlive cache that div (keyed, torn down every navigation) instead
// of the view.
// 4. Both pre-existing wrapper shapes (full-bleed chat/mesh vs. the default
// padded/scrollable shape) survive byte-for-byte via wrapperClass() /
// wrapperStyle() / componentClass() below.
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { RouterView, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
import { useRouteTransitions } from './useRouteTransitions'
import { KEEP_ALIVE_MAX, shouldKeepAlive } from './keepAliveRoutes'
const props = defineProps<{
mobileTabPaddingTop: number | null
needsMobileBackButtonSpace: boolean
}>()
const route = useRoute()
const { getTransitionName } = useRouteTransitions()
const wrapperRef = ref<HTMLElement | null>(null)
type RouteLike = RouteLocationNormalizedLoaded | { path: string }
function isFullBleedRoute(r: RouteLike): boolean {
return r.path === '/dashboard/chat' || r.path === '/dashboard/mesh'
}
/** Classes for the stable outer wrapper — was previously split across two
* v-if/v-else divs; unified here into one so it never needs a per-route key
* (invariant 1) while still producing the exact same visual result. */
function wrapperClass(r: RouteLike) {
if (isFullBleedRoute(r)) {
return [
'h-full',
r.path === '/dashboard/mesh' ? 'dashboard-scroll-panel mobile-scroll-pad mesh-dashboard-panel' : '',
props.mobileTabPaddingTop ? 'overflow-y-auto' : '',
]
}
return [
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto dashboard-scroll-panel',
props.needsMobileBackButtonSpace ? 'mobile-scroll-pad-back' : 'mobile-scroll-pad',
]
}
const wrapperStyle = computed(() =>
props.mobileTabPaddingTop ? { paddingTop: `${props.mobileTabPaddingTop + 16}px` } : undefined,
)
/** Classes merged onto the transitioning component's own root element via
* Vue's attribute fallthrough. `view-wrapper` carries the transition/
* positioning CSS (dashboard-styles.css) that used to live on the outer,
* now-stable wrapper div; `view-container flex-none` matches the default
* shape's previous per-component class. */
function componentClass(r: RouteLike): string {
return isFullBleedRoute(r) ? 'view-wrapper' : 'view-wrapper view-container flex-none'
}
// ── Per-route scroll retention ─────────────────────────────────────────────
// The default shape's wrapper is the scroll container and is now stable
// across navigations (no key), so without this a kept-alive tab would
// inherit whatever scroll offset the previous tab left behind — worse than
// the old reset-to-top. Bounded by the route table (open item, accepted per
// 02-02-PLAN.md assumptions).
const scrollPositions = new Map<string, number>()
watch(
() => route.path,
(newPath, oldPath) => {
if (wrapperRef.value && oldPath) scrollPositions.set(oldPath, wrapperRef.value.scrollTop)
nextTick(() => {
if (wrapperRef.value) wrapperRef.value.scrollTop = scrollPositions.get(newPath) ?? 0
})
},
)
onBeforeUnmount(() => {
if (wrapperRef.value) scrollPositions.set(route.path, wrapperRef.value.scrollTop)
})
</script>