feat(02-02): tracer tab survives KeepAlive round-trip with revalidation
Demo images / Build & push demo images (push) Successful in 3m48s
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user