All checks were successful
Demo images / Build & push demo images (push) Successful in 3m48s
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).
127 lines
5.1 KiB
Vue
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>
|