feat(02-02): tracer tab survives KeepAlive round-trip with revalidation
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).
This commit is contained in:
archipelago 2026-07-30 07:15:17 -04:00
parent bf9c56806c
commit 385c9d866e
7 changed files with 413 additions and 80 deletions

View File

@ -0,0 +1,130 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useCachedResource } from '../useCachedResource'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('useCachedResource', () => {
beforeEach(() => {
setActivePinia(createPinia())
sessionStorage.clear()
})
afterEach(() => {
vi.useRealTimers()
})
it('does not refetch on reactivation within the TTL, and refetches exactly once after the TTL lapses', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 0))
const fetcher = vi.fn().mockResolvedValue('v1')
const Consumer = defineComponent({
setup() {
const resource = useCachedResource<string>({
key: 'test.reactivation-key',
fetcher,
ttlMs: 1000,
persist: false,
})
return () => h('div', resource.data.value ?? '')
},
})
const Other = defineComponent({ render: () => h('div', 'other') })
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Consumer, { key: 'consumer' }) : h(Other, { key: 'other' }),
)
},
})
const wrapper = mount(Host)
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(1)
// Deactivate then reactivate inside the TTL — no additional fetch.
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(1)
// Deactivate, advance past the TTL, reactivate — exactly one more fetch.
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 2)) // +2s, past the 1s TTL
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(2)
})
it('mounts and fetches without throwing outside any KeepAlive boundary', async () => {
const fetcher = vi.fn().mockResolvedValue('bare')
const Consumer = defineComponent({
setup() {
const resource = useCachedResource<string>({ key: 'test.bare-key', fetcher, persist: false })
return () => h('div', resource.data.value ?? '')
},
})
const wrapper = mount(Consumer)
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toBe('bare')
})
it('keeps last-known data and sets error on a rejected refresh, moving loadState ready -> refreshing (not loading)', async () => {
const first = deferred<string>()
const fetcher = vi.fn().mockReturnValueOnce(first.promise)
let resource: ReturnType<typeof useCachedResource<string>> | null = null
const Consumer = defineComponent({
setup() {
resource = useCachedResource<string>({ key: 'test.error-key', fetcher, ttlMs: 1000, persist: false })
return () => h('div', resource!.data.value ?? '')
},
})
mount(Consumer)
await Promise.resolve()
first.resolve('v1')
await flushPromises()
expect(resource!.data.value).toBe('v1')
expect(resource!.loadState.value).toBe('ready')
const second = deferred<string>()
fetcher.mockReturnValueOnce(second.promise)
const refreshCall = resource!.refresh()
await Promise.resolve()
// Sticky-ready: a refresh on already-'ready' data moves to 'refreshing',
// never back to 'loading' — content stays on screen while it runs.
expect(resource!.loadState.value).toBe('refreshing')
second.reject(new Error('offline'))
await refreshCall
await flushPromises()
expect(resource!.data.value).toBe('v1') // keep-last-known-value
expect(resource!.error.value).toBe('offline')
})
})

View File

@ -22,7 +22,7 @@
// - Abort-on-unmount: the fetcher receives an AbortSignal that fires when
// the last subscribed component unmounts.
import { computed, getCurrentScope, onScopeDispose, type ComputedRef } from 'vue'
import { computed, getCurrentScope, onActivated, onScopeDispose, type ComputedRef } from 'vue'
import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources'
export interface CachedResourceOptions<T> {
@ -89,6 +89,15 @@ export function useCachedResource<T>(opts: CachedResourceOptions<T>): CachedReso
window.removeEventListener('focus', onFocus)
aborter.abort()
})
// Reactivation (a KeepAlive'd component being shown again) is a distinct
// trigger from mount and from window focus: onScopeDispose doesn't fire
// on deactivate (the instance is preserved, not destroyed), and the
// focus listener doesn't fire on an in-SPA tab switch (the window never
// loses focus). Without this a kept-alive tab would paint instantly
// forever and never revalidate. Vue no-ops onActivated outside a
// <KeepAlive> boundary, so this is safe for every existing consumer.
onActivated(() => refreshIfStale())
}
if (opts.immediate ?? true) refreshIfStale()

View File

@ -84,38 +84,10 @@
<div class="perspective-container-wrapper glass-piece" :class="{ 'glass-throw-content': showZoomIn && !isHomeRoute }">
<div class="perspective-container">
<RouterView v-slot="{ Component, route }">
<Transition :name="getTransitionName(route)">
<div :key="route.path" class="view-wrapper">
<div
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
:class="[
'h-full',
route.path === '/dashboard/mesh' ? 'dashboard-scroll-panel mobile-scroll-pad mesh-dashboard-panel' : '',
mobileTabPaddingTop ? 'overflow-y-auto' : ''
]"
:style="{ paddingTop: mobileTabPaddingTop ? (mobileTabPaddingTop + 16) + 'px' : undefined }"
class="mobile-safe-top"
>
<component :is="Component" />
</div>
<div
v-else
:class="[
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto mobile-safe-top dashboard-scroll-panel',
needsMobileBackButtonSpace
? 'mobile-scroll-pad-back'
: 'mobile-scroll-pad'
]"
:style="mobileTabPaddingTop ? { paddingTop: (mobileTabPaddingTop + 16) + 'px' } : undefined"
>
<component :is="Component" class="view-container flex-none" />
<!-- Bottom spacer scroll clearance on all pages -->
<div class="shrink-0 h-6 md:h-12" aria-hidden="true"></div>
</div>
</div>
</Transition>
</RouterView>
<DashboardRouterView
:mobile-tab-padding-top="mobileTabPaddingTop"
:needs-mobile-back-button-space="needsMobileBackButtonSpace"
/>
</div>
</div>
@ -143,8 +115,8 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router'
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
import AppSession from '@/views/AppSession.vue'
@ -153,10 +125,11 @@ import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
import DashboardSidebar from '@/views/dashboard/DashboardSidebar.vue'
import DashboardMobileNav from '@/views/dashboard/DashboardMobileNav.vue'
import DashboardRouterView from '@/views/dashboard/DashboardRouterView.vue'
import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
import { isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
import { useIbdFinishWatcher } from '@/composables/useIbdFinishWatcher'
import '@/views/dashboard/dashboard-styles.css'
@ -168,7 +141,6 @@ const route = useRoute()
const store = useAppStore()
const appLauncher = useAppLauncherStore()
const loginTransition = useLoginTransitionStore()
const { getTransitionName } = useRouteTransitions()
const showZoomIn = ref(false)
const pendingTimers: ReturnType<typeof setTimeout>[] = []
@ -234,33 +206,6 @@ const mobileTabPaddingTop = computed(() => {
return mobileNavRef.value?.mobileTabPaddingTop ?? 0
})
// Scroll position save/restore only restore when coming BACK from a detail page
const savedScrollPositions = new Map<string, number>()
let previousRoutePath = ''
function isAnyDetailRoute(path: string): boolean {
return isDetailRoute(path) || WEB5_DETAIL_ROUTES.includes(path)
}
function saveCurrentScroll() {
const el = document.querySelector<HTMLElement>('.perspective-container .view-wrapper > div[class*="overflow-y-auto"]')
if (el && previousRoutePath) {
savedScrollPositions.set(previousRoutePath, el.scrollTop)
}
}
function restoreScroll(path: string) {
const saved = savedScrollPositions.get(path)
if (saved == null) return
nextTick(() => {
// Wait for transition to settle
setTimeout(() => {
const el = document.querySelector<HTMLElement>('.perspective-container .view-wrapper > div[class*="overflow-y-auto"]')
if (el) el.scrollTop = saved
}, 50)
})
}
function activateMainScroll() {
const active = document.activeElement as HTMLElement | null
if (active?.closest?.('[data-controller-zone="sidebar"]')) {
@ -349,19 +294,6 @@ function onContentTouchEnd(e: TouchEvent) {
watch(() => route.path, (newPath) => {
const isAppDetails = isDetailRoute(newPath)
const wasAppDetails = showAltBackground.value
const oldPath = previousRoutePath
const wasDetail = isAnyDetailRoute(oldPath)
const isDetail = isAnyDetailRoute(newPath)
// Save scroll position of the page we're leaving
saveCurrentScroll()
// Restore scroll only when returning from a detail page to the parent list
if (wasDetail && !isDetail) {
restoreScroll(newPath)
}
previousRoutePath = newPath
showAltBackground.value = isAppDetails
@ -374,7 +306,6 @@ watch(() => route.path, (newPath) => {
})
onMounted(() => {
previousRoutePath = route.path
document.body.classList.add('dashboard-active')
if (loginTransition.justCompletedOnboarding) {
// Full glitchy reveal only on the very first dashboard entry

View File

@ -0,0 +1,126 @@
<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>

View File

@ -0,0 +1,103 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router'
import { defineComponent, h, onActivated, onMounted } from 'vue'
import { beforeEach, describe, expect, it } from 'vitest'
import DashboardRouterView from '../DashboardRouterView.vue'
import { shouldKeepAlive } from '../keepAliveRoutes'
let mountCount = 0
let activateCount = 0
let detailMountCount = 0
const KeptAliveStub = defineComponent({
name: 'KeptAliveStub',
setup() {
onMounted(() => { mountCount++ })
onActivated(() => { activateCount++ })
return () => h('div', { class: 'kept-alive-stub' }, 'kept-alive')
},
})
const DetailStub = defineComponent({
name: 'DetailStub',
setup() {
onMounted(() => { detailMountCount++ })
return () => h('div', { class: 'detail-stub' }, 'detail')
},
})
const OtherStub = defineComponent({
name: 'OtherStub',
setup() {
return () => h('div', { class: 'other-stub' }, 'other')
},
})
function makeRouter() {
const routes: RouteRecordRaw[] = [
{ path: '/dashboard/marketplace', component: KeptAliveStub },
{ path: '/dashboard/marketplace/:id', component: DetailStub },
{ path: '/dashboard/other', component: OtherStub },
]
return createRouter({ history: createMemoryHistory(), routes })
}
describe('DashboardRouterView keep-alive behavior', () => {
beforeEach(() => {
mountCount = 0
activateCount = 0
detailMountCount = 0
})
it('keeps the instance alive across a round trip for an included path', async () => {
const router = makeRouter()
router.push('/dashboard/marketplace')
await router.isReady()
const wrapper = mount(DashboardRouterView, {
props: { mobileTabPaddingTop: null, needsMobileBackButtonSpace: false },
global: { plugins: [router] },
})
await flushPromises()
expect(mountCount).toBe(1)
await router.push('/dashboard/other')
await flushPromises()
await router.push('/dashboard/marketplace')
await flushPromises()
expect(mountCount).toBe(1) // never remounted
expect(activateCount).toBe(2) // initial mount + the round-trip reactivation
wrapper.unmount()
})
it('remounts a detail path on every visit (not instance-cached)', async () => {
const router = makeRouter()
router.push('/dashboard/marketplace/abc')
await router.isReady()
const wrapper = mount(DashboardRouterView, {
props: { mobileTabPaddingTop: null, needsMobileBackButtonSpace: false },
global: { plugins: [router] },
})
await flushPromises()
expect(detailMountCount).toBe(1)
await router.push('/dashboard/other')
await flushPromises()
await router.push('/dashboard/marketplace/abc')
await flushPromises()
expect(detailMountCount).toBe(2)
wrapper.unmount()
})
it('shouldKeepAlive returns false for a detail path whose prefix matches an included path', () => {
expect(shouldKeepAlive({ path: '/dashboard/marketplace' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/marketplace/abc' })).toBe(false)
})
})

View File

@ -0,0 +1,31 @@
// Route classification for the Dashboard KeepAlive host (D-01/D-03/D-04).
//
// Exact-match only — a prefix match would sweep in secondary screens like
// `/dashboard/marketplace/:id`, which D-04 explicitly excludes from the
// instance cache (unbounded item counts would bloat it). Deliberately not
// derived from `isDetailRoute()` in useRouteTransitions.ts: that helper only
// recognises `/apps/` and `/marketplace/` details and misses `cloud/:folderId`,
// `server/openwrt`, `web5/credentials`, `goals/:goalId` and `app-session/:appId`.
import type { RouteLocationNormalizedLoaded } from 'vue-router'
/** Maximum number of main-tab component instances kept alive at once (D-03).
* Bounds memory on low-power fleet nodes the oldest unused instance evicts
* once this cap is exceeded (Vue's own LRU eviction). */
export const KEEP_ALIVE_MAX = 6
/** Paths whose component instance survives a tab switch (D-01).
*
* Seeded with only the tracer tab (Marketplace) for this plan plan 02-04
* widens this set after auditing every main tab's mount/activate lifecycle.
* Do not add paths here without doing that audit first: a view with an
* un-audited side effect in `onMounted` that should have moved to
* `onActivated`/`onDeactivated` would misbehave the moment it's kept alive. */
export const KEEP_ALIVE_PATHS: ReadonlySet<string> = new Set([
'/dashboard/marketplace',
])
/** True only for an exact path match against KEEP_ALIVE_PATHS. */
export function shouldKeepAlive(route: RouteLocationNormalizedLoaded | { path: string }): boolean {
return KEEP_ALIVE_PATHS.has(route.path)
}

View File

@ -1,7 +1,10 @@
import { type RouteLocationNormalizedLoaded } from 'vue-router'
/** Tab order for vertical transitions between main navigation items */
const TAB_ORDER = [
/** Tab order for vertical transitions between main navigation items.
* Exported so keepAliveRoutes.ts (and plan 02-04's widening of
* KEEP_ALIVE_PATHS) can derive the main-tab path set from a single source
* of truth instead of re-deriving it. */
export const TAB_ORDER = [
'/dashboard',
'/dashboard/apps',
'/dashboard/marketplace',