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
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:
parent
bf9c56806c
commit
385c9d866e
130
neode-ui/src/composables/__tests__/useCachedResource.test.ts
Normal file
130
neode-ui/src/composables/__tests__/useCachedResource.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
126
neode-ui/src/views/dashboard/DashboardRouterView.vue
Normal file
126
neode-ui/src/views/dashboard/DashboardRouterView.vue
Normal 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>
|
||||
103
neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts
Normal file
103
neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
31
neode-ui/src/views/dashboard/keepAliveRoutes.ts
Normal file
31
neode-ui/src/views/dashboard/keepAliveRoutes.ts
Normal 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)
|
||||
}
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user