34 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-ui-performance | 02 | execute | 2 |
|
|
false |
|
|
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.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.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 `**A. Route classifier.** Create `neode-ui/src/views/dashboard/keepAliveRoutes.ts`
exporting `KEEP_ALIVE_MAX` (set to 6), `KEEP_ALIVE_PATHS` (a `ReadonlySet<string>`
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 `<KeepAlive :include>` 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
`<RouterView v-slot="{ Component, route }">` 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 `<DashboardRouterView :mobile-tab-padding-top="..."
:needs-mobile-back-button-space="..." />`.
Four structural invariants govern the new template, and the tests below exist to
pin them:
1. Nothing between the RouterView slot and `<KeepAlive>` may carry a binding that
changes identity per route. The current `<div :key="route.path">` sits exactly
there; if a `<KeepAlive>` 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 `<Transition>` outside `<KeepAlive>` outside
`<component :is="Component">`.
3. The `:key="route.path"` binding belongs on `<component :is>` itself, never on an
ancestor of `<KeepAlive>`.
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
`<Transition><KeepAlive :max="KEEP_ALIVE_MAX"><component :is="Component"
:key="route.path" v-if="shouldKeepAlive(route)" /></KeepAlive></Transition>` branch
and a plain `<Transition><component :is="Component" :key="route.path"
v-else /></Transition>` 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<string, number>` 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 `<KeepAlive>` 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 `<KeepAlive>` 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 `<KeepAlive>` 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.
Then create `neode-ui/src/components/RefreshIndicator.vue`: a small presentational
component taking `state: ResourceLoadState` and an optional `label?: string`. It
renders a compact spinner or shimmer sized to sit inline in a view header — small
enough to read as ambient rather than as a blocking loader. Match the existing
design system: reuse the spinner treatment already present in the codebase (for
example the `chat-loading-spinner` rule in `Chat.vue`'s scoped styles) rather than
inventing a second spinner idiom, and use the same glass/white-alpha palette as its
neighbours. Exact placement and styling are Claude's discretion per CONTEXT.md, but
it must not shift layout when it appears and disappears — reserve its space or
position it absolutely.
Wire it into the tracer tab's header, bound to the tab's primary resource
`loadState`. Do not surface stale-age text or a "last updated" badge — D-05 rules
those out.
Then correct the tracer tab's per-visit behavior now that its instance survives.
`onMounted` fires exactly once for the lifetime of a kept-alive instance, so audit
every side effect in that view and place each one deliberately:
- Genuinely once-per-session setup stays in `onMounted`.
- Anything that should re-run on every tab entry moves to `onActivated`.
- Anything that should stop while the tab is off screen (intervals, subscriptions,
window listeners) gains a matching `onDeactivated` teardown, with `onActivated`
re-arming it.
For `Marketplace.vue` specifically: `marketplaceAnimationDone` is a one-shot intro
flag and stays where it is; the catalog and prune-status loads are now cache-gated
and revalidate through the hook's own `onActivated`, so they need no per-view hook.
Record in the SUMMARY every side effect you moved and every one you deliberately
left in `onMounted`, with the reason — plan 02-04 repeats this audit across the
remaining tabs and needs the precedent.
Confirm the error path matches D-07: a failed background refresh keeps the last
known content on screen and sets the view's existing error banner ref; it must not
call the toast composable. Errors surface on an explicit user-triggered refresh only.
Extend the existing test file with an indicator test: mount `RefreshIndicator` for
each `ResourceLoadState` value and assert the render-nothing / render-something
matrix in the behavior block above.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| node RPC / HTTP responses → browser cache | Untrusted-until-validated response payloads now live longer, in memory and in sessionStorage |
| browser tab session → sessionStorage | Cached payloads survive in-tab navigation and reload, readable by any script running on the origin |
| authenticated session → cached view instances | A kept-alive component instance holds rendered data across navigations and across a logout |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-02-01 | Information Disclosure | useCachedResource default persist: true writing to sessionStorage |
high | mitigate | Task 1D requires an explicit per-resource persist decision rather than the default. The two tracer-tab resources (app-catalog, bitcoin.prune-status) are non-sensitive and persist; any resource carrying wallet figures, transaction history, credentials, DIDs or peer identity is memory-only (persist: false) |
| T-02-02 | Information Disclosure | Cached entries surviving a logout or identity switch | high | mitigate | Cache keys used here are node-global and non-identity-bearing. Purging the resources store and its resource: sessionStorage prefix on logout is specified and verified in plan 02-03 Task 3, which owns the identity-scoping work; this plan must not introduce an identity-bearing key before that lands |
| T-02-03 | Denial of Service | <KeepAlive> instance cache on low-power fleet nodes |
medium | mitigate | KEEP_ALIVE_MAX of 6 caps resident instances with LRU eviction (D-03); Task 2 requires intervals and subscriptions to stop on onDeactivated so an off-screen tab costs no CPU; on-device memory is verified in plan 02-08 |
| T-02-09 | Tampering | Restructured Dashboard.vue render path |
low | accept | The change is render-composition only — no auth guard, route guard or data-validation path is touched. router/index.ts's existing navigation guards are unmodified |
| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope. <KeepAlive> is Vue core, onActivated is Vue core, and every other primitive already ships in this repo. If a task finds it needs a new dependency it stops and routes through the Package Legitimacy Gate with a blocking human checkpoint before installing |
| </threat_model> |
<artifacts_this_phase_produces>
Artifacts this phase produces
Symbols and paths created by this plan — new API, not drift from the existing codebase:
neode-ui/src/views/dashboard/keepAliveRoutes.ts—shouldKeepAlive(),KEEP_ALIVE_PATHS,KEEP_ALIVE_MAXneode-ui/src/views/dashboard/DashboardRouterView.vue— propsmobileTabPaddingTop,needsMobileBackButtonSpace; internal helpersisFullBleedRoute(),wrapperClass(),wrapperStyle()neode-ui/src/components/RefreshIndicator.vue— propsstate: ResourceLoadState,label?: stringneode-ui/src/composables/__tests__/useCachedResource.test.tsneode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.tsTAB_ORDER— promoted from module-private to an export ofneode-ui/src/views/dashboard/useRouteTransitions.ts- Cache keys introduced:
app-catalog,bitcoin.prune-status
Created elsewhere in Phase 02: neode-ui/e2e/perf/{surfaces,measure,surface-perf.spec}.ts,
.planning/phases/02-ui-performance/{02-FINDINGS.md,02-PERF-BASELINE.json,02-PERF-AFTER.json}.
</artifacts_this_phase_produces>
<assumptions_and_flagged_items>
Assumptions & Flagged Items
- PERF-02 edge-probe row (spec-less fallback): returned
unclassified/unresolved. FLAGGED, not auto-backstopped and not dropped. Resolved in substance by this plan'smust_haves.truths; one truth (transition-animation parity) is carried as averification: backstopmarker because it is a perceptual property the unit tests cannot confirm. The probe row itself stays unresolved and is surfaced here for human review. - FA-A (correction to
02-PATTERNS.md): PATTERNS.md line 43 advises leaving:key="route.path"on the outer wrapper<div>. That is unsafe once a<KeepAlive>is nested beneath it — a keyed ancestor is torn down on every navigation and destroys the instance cache, producing a change that reads correct and improves nothing. Task 1's round-trip mount-count assertion is the resolution. - FA-C (RESEARCH assumption A1): whether Vue 3.5.24 fixes
KeepAliveinclude/excludename matching for async components is not settled. Resolved by design — this plan never usesinclude/exclude; classification is by route path. - FA-D (RESEARCH assumption A2): the
maxcap value. Set to 6 against 10 entries inTAB_ORDER, so the long tail evicts while a normal working set stays resident. Not validated on hardware yet; plan 02-08 tunes it against on-device memory. - FA-F (correction to
02-CONTEXT.mdcanonical_refs):App.vue's RouterView is not the remount point — it only ever swapsOnboardingWrapper,DashboardandNotFound. The real point is the nested RouterView inDashboard.vue, extracted here toDashboardRouterView.vue. - Open: the scroll-retention
Mapis unbounded in principle (one number per visited path). Path count is bounded by the route table, so this is accepted rather than mitigated. </assumptions_and_flagged_items>
<success_criteria>
- One main tab renders instantly from a surviving component instance on revisit, with no spinner and no blank frame
- A stale return fires exactly one background revalidation, visible as a subtle indicator, with content never leaving the screen
- A failed background refresh is silent and non-destructive
- Secondary screens are unaffected — they still mount fresh
- The instance cache is capped, and the shared classifier, host component and hook fix are in place for every later plan to build on </success_criteria>