diff --git a/neode-ui/src/composables/__tests__/useCachedResource.test.ts b/neode-ui/src/composables/__tests__/useCachedResource.test.ts new file mode 100644 index 00000000..bd105337 --- /dev/null +++ b/neode-ui/src/composables/__tests__/useCachedResource.test.ts @@ -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() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((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({ + 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({ 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() + const fetcher = vi.fn().mockReturnValueOnce(first.promise) + let resource: ReturnType> | null = null + + const Consumer = defineComponent({ + setup() { + resource = useCachedResource({ 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() + 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') + }) +}) diff --git a/neode-ui/src/composables/useCachedResource.ts b/neode-ui/src/composables/useCachedResource.ts index 28ed43a5..c3080e41 100644 --- a/neode-ui/src/composables/useCachedResource.ts +++ b/neode-ui/src/composables/useCachedResource.ts @@ -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 { @@ -89,6 +89,15 @@ export function useCachedResource(opts: CachedResourceOptions): 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 + // boundary, so this is safe for every existing consumer. + onActivated(() => refreshIfStale()) } if (opts.immediate ?? true) refreshIfStale() diff --git a/neode-ui/src/views/Dashboard.vue b/neode-ui/src/views/Dashboard.vue index c0306844..bfc02334 100644 --- a/neode-ui/src/views/Dashboard.vue +++ b/neode-ui/src/views/Dashboard.vue @@ -84,38 +84,10 @@
- - -
-
- -
-
- - - -
-
-
-
+
@@ -143,8 +115,8 @@ diff --git a/neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts b/neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts new file mode 100644 index 00000000..7dda4367 --- /dev/null +++ b/neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts @@ -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) + }) +}) diff --git a/neode-ui/src/views/dashboard/keepAliveRoutes.ts b/neode-ui/src/views/dashboard/keepAliveRoutes.ts new file mode 100644 index 00000000..ea3d81c7 --- /dev/null +++ b/neode-ui/src/views/dashboard/keepAliveRoutes.ts @@ -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 = 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) +} diff --git a/neode-ui/src/views/dashboard/useRouteTransitions.ts b/neode-ui/src/views/dashboard/useRouteTransitions.ts index 1a127489..f1bdcacf 100644 --- a/neode-ui/src/views/dashboard/useRouteTransitions.ts +++ b/neode-ui/src/views/dashboard/useRouteTransitions.ts @@ -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',