` that then branches into two wrapper shapes. This is the structure being restructured; read it before touching it.
- `neode-ui/src/composables/useCachedResource.ts` — all 107 lines. Note `refreshIfStale()` (line ~72), `stale()` (line ~71), the `getCurrentScope()` + `onScopeDispose` block (lines ~86-92), and the `if (opts.immediate ?? true) refreshIfStale()` call (line ~94) whose placement the new hook mirrors.
- `neode-ui/src/stores/resources.ts` — the backing store; `refresh()` already dedupes concurrent calls per key via its `inflight` map, which is why an extra reactivation-triggered call on first mount is harmless.
- `neode-ui/src/views/dashboard/useRouteTransitions.ts` — `TAB_ORDER` (lines 4-15) is the canonical main-tab path list; `getTransitionName()` must keep working unchanged; `isDetailRoute()` is deliberately NOT the classifier used here.
- `neode-ui/src/router/index.ts` — the dashboard child routes and their `name` values; confirm the tracer tab's path and that detail routes such as `apps/:id` and `marketplace/:id` are siblings under the same parent.
- `neode-ui/src/views/Marketplace.vue` — the tracer tab (unless the findings ranking says otherwise). Read `onMounted` at line ~377, `loadCommunityMarketplace()`, `loadBitcoinPruneStatus()` (fetches `/bitcoin-status`), and the `marketplaceAnimationDone` one-shot flag.
- `neode-ui/src/views/Cloud.vue` lines 505-530 and 945-970 — the in-repo reference for defining a cached resource and for the keep-last-value error handling to mirror.
- `neode-ui/src/views/__tests__/CloudPeersRefresh.test.ts` — the in-repo Vitest + `@vue/test-utils` + Pinia mounting pattern to follow for the new tests.
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — the ranked fix order that selects the tracer tab, and the measured cause for that tab.
Write the failing tests first, then make them pass.
**A. Route classifier.** Create `neode-ui/src/views/dashboard/keepAliveRoutes.ts`
exporting `KEEP_ALIVE_MAX` (set to 6), `KEEP_ALIVE_PATHS` (a `ReadonlySet`
seeded with ONLY the tracer tab's path — plan 02-04 widens it after the lifecycle
audit), and `shouldKeepAlive(route: RouteLocationNormalizedLoaded | { path: string })`
returning true only for an exact path match. Exact-match, not prefix-match: a
prefix match would sweep in `/dashboard/marketplace/:id` and every other secondary
screen, which D-04 forbids from the instance cache. Do not derive this from
`isDetailRoute()` — that helper only recognises `/apps/` and `/marketplace/` details
and misses `cloud/:folderId`, `server/openwrt`, `web5/credentials`, `goals/:goalId`
and `app-session/:appId`. Export `TAB_ORDER` from `useRouteTransitions.ts` (it is
currently a module-private `const`) so plan 02-04 can widen `KEEP_ALIVE_PATHS` from
it without editing that file again.
Deliberately do not use `` name matching. Every one of the 44
routes is an async component (`component: () => import(...)`) and no view in this
codebase calls `defineOptions({ name })`, so `include` would depend on name
inference through the async wrapper — the failure mode RESEARCH.md flags as
assumption A1 (vuejs/core issue 11764). Route-path classification sidesteps it and
is also the fix for RESEARCH.md pitfall 7.
**B. Extract and restructure the KeepAlive host.** Create
`neode-ui/src/views/dashboard/DashboardRouterView.vue` holding the nested
`` currently inlined in `Dashboard.vue`,
taking `mobileTabPaddingTop: number | null` and `needsMobileBackButtonSpace: boolean`
as props (both are computed in `Dashboard.vue` today). Replace that inline block in
`Dashboard.vue` with ``.
Four structural invariants govern the new template, and the tests below exist to
pin them:
1. Nothing between the RouterView slot and `` may carry a binding that
changes identity per route. The current `` sits exactly
there; if a `
` is nested under it, that div is torn down on every
navigation and takes the entire instance cache with it, producing a change that
reviews clean and improves nothing. Hoist the wrapper out and drive its
appearance from route-derived computed values instead of from a changing key.
2. Composition order is `` outside `` outside
``.
3. The `:key="route.path"` binding belongs on `` itself, never on an
ancestor of ``.
4. Both existing wrapper shapes must survive byte-for-byte in their visual result:
the chat/mesh branch (`h-full`, plus `dashboard-scroll-panel mobile-scroll-pad
mesh-dashboard-panel` for the mesh path, plus `overflow-y-auto` and the
`mobileTabPaddingTop + 16` padding when that prop is set, plus `mobile-safe-top`)
and the default branch (`absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto
mobile-safe-top dashboard-scroll-panel`, plus `mobile-scroll-pad-back` or
`mobile-scroll-pad`, the `view-container flex-none` class applied to the rendered
component, and the trailing `shrink-0 h-6 md:h-12` spacer div).
Express the two shapes as computed helpers in the new component (for example
`isFullBleedRoute(route)`, `wrapperClass(route)`, `wrapperStyle(route)`) applied to a
single stable wrapper element, and render two sibling branches inside it: a
`` branch
and a plain `` branch for everything else. `getTransitionName(route)` keeps
driving both.
Because the default branch's wrapper is the scroll container and it is now stable
across routes, add explicit per-route scroll retention in the new component: keep a
`Map` of `scrollTop` by route path, write the outgoing path's value
in a `watch` on `route.path` before the new view paints, and restore the incoming
path's value on `nextTick` after it does. Without this, a kept-alive tab would inherit
the previous tab's scroll offset, which is worse than today's reset-to-top.
**C. Close the reactivation gap in the hook.** In
`neode-ui/src/composables/useCachedResource.ts`, import `onActivated` from `vue` and
register `onActivated(() => refreshIfStale())` inside the existing
`if (getCurrentScope())` block, alongside `onScopeDispose`. Vue no-ops this hook
outside a `` boundary, so it is safe for all eight existing consumers.
Without it a kept-alive tab paints instantly forever and never revalidates, because
`onScopeDispose` does not fire on deactivate and the `window` focus listener does not
fire on an in-SPA tab switch. Add a short comment above it naming why reactivation is
a distinct trigger from mount and from focus.
**D. Register the tracer tab.** Seed `KEEP_ALIVE_PATHS` with exactly the tracer tab's
path and nothing else. Its data conversion lands in Task 2; this task proves the
instance survives and that the hook revalidates on reactivation, which is the
architectural question. Plan 02-04 widens the set after auditing every tab's
lifecycle — do not widen it here.
**E. The tests.** Create
`neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` mounting
`DashboardRouterView` with a `createRouter` on `createMemoryHistory` and two stub
route components that each increment a module-level mount counter in `onMounted` and
an activation counter in `onActivated`. Assert: navigating to the kept-alive path,
away, and back leaves the mount counter at 1 and the activation counter at 2; the
same round-trip on a detail path such as `/dashboard/marketplace/abc` leaves that
stub's mount counter at 2; and `shouldKeepAlive` returns false for a detail path
whose prefix matches an included path.
Create `neode-ui/src/composables/__tests__/useCachedResource.test.ts` mounting a
consumer component inside a real `` with a `vi.fn()` fetcher. Assert:
deactivate and reactivate inside the TTL calls the fetcher no additional times;
deactivate, advance fake timers past the TTL, reactivate calls it exactly once more;
the same hook used outside any `` mounts and fetches without throwing; a
rejected refresh leaves `entry.data` at its previous value with `entry.error` set;
and `loadState` moves ready to refreshing rather than back to loading.
cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts src/composables/__tests__/useCachedResource.test.ts && npm run type-check
- `npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts` exits 0
- `npm run test -- src/composables/__tests__/useCachedResource.test.ts` exits 0
- `npm run type-check` exits 0
- `npm run test` (full suite) exits 0 — the eight existing `useCachedResource` consumers are unregressed
- In the round-trip test the kept-alive stub records exactly 1 mount and 2 activations; the detail-route stub records 2 mounts
- `neode-ui/src/views/dashboard/keepAliveRoutes.ts` exports `shouldKeepAlive`, `KEEP_ALIVE_PATHS` and `KEEP_ALIVE_MAX`, and `shouldKeepAlive({ path: '/dashboard/marketplace/abc' })` returns false
- `neode-ui/src/composables/useCachedResource.ts` imports `onActivated` from `vue`: `grep -c "onActivated" neode-ui/src/composables/useCachedResource.ts` is at least 2
- `neode-ui/src/views/dashboard/DashboardRouterView.vue` contains `KeepAlive` and `shouldKeepAlive`, and `neode-ui/src/views/Dashboard.vue` renders `DashboardRouterView`
- `KEEP_ALIVE_PATHS` contains exactly one entry — the tracer tab's path
- `npm run build` exits 0 and the built bundle carries the new code: after building, `grep -rl "shouldKeepAlive\|KeepAlive" web/dist/neode-ui/assets | head -1` prints a file (CLAUDE.md warns the frontend build can silently no-op)
The tracer tab renders from a surviving component instance on revisit, the hook revalidates on reactivation only when stale, a detail route still mounts fresh, and the full Vitest suite is green.
Task 2: Subtle refresh indicator and correct per-visit behavior on the tracer tab
neode-ui/src/components/RefreshIndicator.vue, neode-ui/src/views/Marketplace.vue, neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts
- `neode-ui/src/views/Marketplace.vue` — the tracer tab as left by Task 1; read its header/toolbar markup to find where a refresh affordance belongs, and its `marketplaceAnimationDone` one-shot flag at `onMounted` (line ~377)
- `neode-ui/src/components/` — list it and read two or three existing small components to match the house glass/dark styling, spacing and `