From 385c9d866e8a47b7b167acbe70f77ac264d81d31 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 30 Jul 2026 07:15:17 -0400 Subject: [PATCH] feat(02-02): tracer tab survives KeepAlive round-trip with revalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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',