--- phase: 02-ui-performance plan: 02 type: execute wave: 2 depends_on: ["02-01"] files_modified: - neode-ui/src/composables/useCachedResource.ts - neode-ui/src/composables/__tests__/useCachedResource.test.ts - neode-ui/src/views/dashboard/keepAliveRoutes.ts - neode-ui/src/views/dashboard/DashboardRouterView.vue - neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts - neode-ui/src/views/dashboard/useRouteTransitions.ts - neode-ui/src/views/Dashboard.vue - neode-ui/src/components/RefreshIndicator.vue - neode-ui/src/views/Marketplace.vue autonomous: false requirements: [PERF-02] must_haves: truths: - "Switching away from the tracer main tab and back renders its content with no spinner and no blank frame, from the surviving component instance" - "The tracer tab's component instance is reused across a tab round-trip — it mounts once per session, not once per visit" - "Returning to the tracer tab within the TTL issues no new RPC for its cached resource" - "Returning to the tracer tab after the TTL has lapsed issues exactly one background revalidation and keeps the previous content on screen while it runs (D-01)" - "While that background revalidation is in flight a subtle refresh indicator is visible, driven by loadState === 'refreshing' (D-05)" - "A failed background refresh leaves the last known content on screen and raises no toast (D-07)" - "A secondary screen reached from a tab's main page is not instance-cached — it mounts fresh each visit (D-04)" - "The number of cached view instances is capped, so visiting every main tab does not grow the instance cache without bound (D-03)" - "Scroll position within a main tab is restored on return rather than reset to the top" - statement: "The route transition animations that played before the KeepAlive restructure still play afterwards, with the same names for the same navigations" verification: backstop prohibitions: - "MUST NOT present cached data as live — a money- or liveness-critical surface (wallet balance, incoming payment, mesh peer reachability, app install or health state) must never render from cache without a visible refresh signal and an in-flight revalidation" - "MUST NOT persist wallet balances, transaction history, credentials, DIDs, seed or identity material, or peer identity payloads to sessionStorage" - "MUST NOT achieve perceived speed by removing behavior or hiding state — no suppressing the refresh indicator, no dropping a fetch the surface needs, no disabling a feature to win the metric" artifacts: - path: "neode-ui/src/views/dashboard/keepAliveRoutes.ts" provides: "The single source of truth for which routes are instance-cached, plus the instance cap" exports: ["shouldKeepAlive", "KEEP_ALIVE_PATHS", "KEEP_ALIVE_MAX"] - path: "neode-ui/src/views/dashboard/DashboardRouterView.vue" provides: "The extracted, testable KeepAlive host — the nested RouterView that actually remounts on tab switch" - path: "neode-ui/src/components/RefreshIndicator.vue" provides: "Subtle background-refresh indicator driven by a loadState prop (D-05)" - path: "neode-ui/src/composables/__tests__/useCachedResource.test.ts" provides: "Coverage for the onActivated revalidation and the preserved sticky-ready / keep-last-value semantics" - path: "neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts" provides: "Proof that an included route's instance survives a round-trip and an excluded route's does not" key_links: - from: "neode-ui/src/views/dashboard/DashboardRouterView.vue" to: "neode-ui/src/views/dashboard/keepAliveRoutes.ts" via: "calls shouldKeepAlive(route) to decide which branch renders the view" pattern: "shouldKeepAlive" - from: "neode-ui/src/composables/useCachedResource.ts" to: "vue onActivated" via: "reactivation triggers refreshIfStale so a KeepAlive'd tab still background-refreshes" pattern: "onActivated" - from: "neode-ui/src/views/Marketplace.vue" to: "neode-ui/src/composables/useCachedResource.ts" via: "the tracer tab's catalog and status fetches move onto keyed cached resources" pattern: "useCachedResource" - from: "neode-ui/src/views/Dashboard.vue" to: "neode-ui/src/views/dashboard/DashboardRouterView.vue" via: "Dashboard renders the extracted host in place of its inline nested RouterView" pattern: "DashboardRouterView" --- PHASE TRACER. Wire one main tab end to end through every layer this phase touches — route classification, the KeepAlive host inside `Dashboard.vue`'s nested RouterView, the `useCachedResource` reactivation gap, the tab's own data fetches, and the subtle refresh indicator — and prove with a runnable test that the tab renders from cache on revisit while revalidating in the background. This is the phase's thin end-to-end slice, sequenced immediately after the D-10 profiling gate (plan 02-01), which CONTEXT.md locks as a hard prerequisite: no production source may change before the findings doc is committed. Every later plan in this phase expands horizontally from the architecture proven here. It is production quality, not a prototype — the only thing "thin" about it is that exactly one tab is converted. **Tracer tab selection:** use the highest-ranked slow main tab from `02-FINDINGS.md` `## Ranked Fix Order`. Default and expected pick: `marketplace` (`neode-ui/src/views/Marketplace.vue`) — the app store the user reported as the worst surface, moderate size, and it exercises all three layers (instance cache, data cache, refresh indicator). If the ranking's top entry is `mesh`, take the next entry instead: `Mesh.vue` is 2,651 lines with a live D3 force graph and a Leaflet map, which exceeds a single task's context budget and is planned separately as 02-05. Record the pick and the reason in the SUMMARY. Purpose: PERF-02 — main-tab switches render immediately from cached state with background refresh. Proving the whole path on one tab first means an architectural dead end costs one commit instead of ten. Output: a working, instance-cached, stale-while-revalidate main tab; the shared KeepAlive host and route classifier every other tab will use; the hook fix that makes background refresh actually fire on revisit; and the tests that pin all of it. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-ui-performance/02-CONTEXT.md @.planning/phases/02-ui-performance/02-RESEARCH.md @.planning/phases/02-ui-performance/02-PATTERNS.md @.planning/phases/02-ui-performance/02-FINDINGS.md @.planning/codebase/CONVENTIONS.md @CLAUDE.md Task 1: One main tab survives a tab round-trip and revalidates on return `Dashboard.vue`'s nested RouterView is the single mount point every dashboard view renders through, and `useCachedResource` already has eight consumers — undoing either shape later means touching every view again. neode-ui/src/composables/useCachedResource.ts, neode-ui/src/composables/__tests__/useCachedResource.test.ts, neode-ui/src/views/dashboard/keepAliveRoutes.ts, neode-ui/src/views/dashboard/DashboardRouterView.vue, neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts, neode-ui/src/views/dashboard/useRouteTransitions.ts, neode-ui/src/views/Dashboard.vue - `neode-ui/src/views/Dashboard.vue` — read the template around lines 87-118. The nested `` sits inside ``, whose child is a `
` 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 `