Files
archy/.planning/phases/02-ui-performance/02-02-PLAN.md
T

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
02-01
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
false
PERF-02
truths prohibitions artifacts key_links
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 verification
The route transition animations that played before the KeepAlive restructure still play afterwards, with the same names for the same navigations backstop
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
path provides exports
neode-ui/src/views/dashboard/keepAliveRoutes.ts The single source of truth for which routes are instance-cached, plus the instance cap
shouldKeepAlive
KEEP_ALIVE_PATHS
KEEP_ALIVE_MAX
path provides
neode-ui/src/views/dashboard/DashboardRouterView.vue The extracted, testable KeepAlive host — the nested RouterView that actually remounts on tab switch
path provides
neode-ui/src/components/RefreshIndicator.vue Subtle background-refresh indicator driven by a loadState prop (D-05)
path provides
neode-ui/src/composables/__tests__/useCachedResource.test.ts Coverage for the onActivated revalidation and the preserved sticky-ready / keep-last-value semantics
path provides
neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts Proof that an included route's instance survives a round-trip and an excluded route's does not
from to via pattern
neode-ui/src/views/dashboard/DashboardRouterView.vue neode-ui/src/views/dashboard/keepAliveRoutes.ts calls shouldKeepAlive(route) to decide which branch renders the view shouldKeepAlive
from to via pattern
neode-ui/src/composables/useCachedResource.ts vue onActivated reactivation triggers refreshIfStale so a KeepAlive'd tab still background-refreshes onActivated
from to via pattern
neode-ui/src/views/Marketplace.vue neode-ui/src/composables/useCachedResource.ts the tracer tab's catalog and status fetches move onto keyed cached resources useCachedResource
from to via pattern
neode-ui/src/views/Dashboard.vue neode-ui/src/views/dashboard/DashboardRouterView.vue Dashboard renders the extracted host in place of its inline nested RouterView 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.

<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 `
` 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<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.
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 `<script setup lang="ts">` prop-typing conventions - `neode-ui/src/stores/resources.ts` — the `ResourceLoadState` union (`idle | loading | ready | refreshing | error`) that the indicator's prop is typed against - `.planning/codebase/CONVENTIONS.md` — component file structure, `defineProps<{}>()` typing, and the "types not enums" rule - `.planning/phases/02-ui-performance/02-CONTEXT.md` — D-05 (subtle indicator, no stale-age badges), D-07 (silent keep-last-value), D-08 (persist policy) - RefreshIndicator renders nothing when `state` is `ready` or `idle` - RefreshIndicator renders its indicator element when `state` is `refreshing` - RefreshIndicator renders nothing when `state` is `loading` — a first load is the view's own skeleton's job, not this component's - The indicator element carries an accessible label and `aria-live="polite"` so a background refresh is announced without stealing focus - When a background refresh on the tracer tab rejects, the previously rendered content is still in the DOM and no toast function is called First put the tracer tab's data on the cache, following the `Cloud.vue` pattern: a keyed resource per logical dataset, `computed` views over `entry.data` and `entry.loadState`, and keep-last-value error handling that sets a banner ref rather than raising a toast (D-07). For `Marketplace.vue` that means the shared app-catalog fetch behind `loadCommunityMarketplace()` (key `app-catalog`, a long TTL of 300000 ms — the catalog is near-static, per the D-06 discretion) and the `/bitcoin-status` fetch behind `loadBitcoinPruneStatus()` (key `bitcoin.prune-status`, the 30000 ms default). Put the catalog fetch behind a shared key rather than a Marketplace-private one so `Discover.vue`, which calls the same loader, picks up the same cache entry without its own conversion in plan 02-04. Decide `persist` explicitly per resource rather than taking the default: `app-catalog` and `bitcoin.prune-status` are non-sensitive and small, so both persist. Pass `dedup: true` on the underlying calls.
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.
cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts && npm run type-check && npm run test - `neode-ui/src/components/RefreshIndicator.vue` exists and its props are typed with `defineProps<{ state: ResourceLoadState; label?: string }>()` - Mounting `RefreshIndicator` with `state: 'refreshing'` renders a non-empty element; with `state: 'ready'`, `'idle'` and `'loading'` it renders nothing - The rendered indicator element carries `aria-live="polite"` and a non-empty accessible label - The tracer tab view imports `useCachedResource`, defines the `app-catalog` and `bitcoin.prune-status` keys with explicit `ttlMs` and `persist` values, and no longer calls those loaders from `onMounted` without a cached resource behind them - The tracer tab view imports and renders `RefreshIndicator` bound to a resource `loadState` - `npm run test` exits 0 and `npm run type-check` exits 0 - A rejected background refresh in the tracer tab test leaves the prior data rendered and invokes no toast - The SUMMARY lists each side effect that moved to `onActivated`/`onDeactivated` and each one deliberately left in `onMounted`, with reasons The tracer tab shows a subtle, non-layout-shifting refresh indicator during background revalidation, keeps its content on a failed refresh without a toast, and every one of its side effects is deliberately placed for the kept-alive lifecycle. Task 3: Confirm the tracer tab feels instant on the dev preview against archi-dev The KeepAlive host inside `Dashboard.vue`'s nested RouterView (extracted to `DashboardRouterView.vue`), a route-path classifier capping the instance cache at 6, the `onActivated` revalidation fix in `useCachedResource`, the tracer tab's data moved onto keyed cached resources, and a subtle refresh indicator. Automated proof already passing: component instance survives a tab round-trip, detail routes still remount, no refetch inside the TTL, exactly one background refetch after it. 1. From the repo root run `./scripts/dev-start.sh` and open the :8100 dev preview pointed at archi-dev, per the phase's dev discipline (password `password123`). 2. Open the tracer tab (the app store / Marketplace unless the SUMMARY says otherwise). Let it finish loading. 3. Switch to another main tab, then switch back. Expected: content appears immediately — no spinner, no blank frame, no intro animation replay. Search text, selected category and scroll position are as you left them. 4. Stay on another tab for longer than the TTL, then return. Expected: content is still there instantly, and the small refresh indicator appears briefly in the header while the data revalidates behind it. No full-screen loader, no layout jump. 5. Open a secondary screen from that tab (tap an app to reach its detail page), go back, and open a different app. Expected: the detail screen behaves as before — this plan deliberately does not instance-cache secondary screens. 6. Confirm the tab transition animation still plays when moving between main tabs, and that it is the same animation as before this change. 7. Stop the backend (or pull the node's network) and return to the tracer tab after the TTL. Expected: the previous content stays on screen, no error toast appears. Type "approved", or describe what you saw: which step, what happened instead.

<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.tsshouldKeepAlive(), KEEP_ALIVE_PATHS, KEEP_ALIVE_MAX
  • neode-ui/src/views/dashboard/DashboardRouterView.vue — props mobileTabPaddingTop, needsMobileBackButtonSpace; internal helpers isFullBleedRoute(), wrapperClass(), wrapperStyle()
  • neode-ui/src/components/RefreshIndicator.vue — props state: ResourceLoadState, label?: string
  • neode-ui/src/composables/__tests__/useCachedResource.test.ts
  • neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts
  • TAB_ORDER — promoted from module-private to an export of neode-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's must_haves.truths; one truth (transition-animation parity) is carried as a verification: backstop marker 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 KeepAlive include/exclude name matching for async components is not settled. Resolved by design — this plan never uses include/exclude; classification is by route path.
  • FA-D (RESEARCH assumption A2): the max cap value. Set to 6 against 10 entries in TAB_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.md canonical_refs): App.vue's RouterView is not the remount point — it only ever swaps OnboardingWrapper, Dashboard and NotFound. The real point is the nested RouterView in Dashboard.vue, extracted here to DashboardRouterView.vue.
  • Open: the scroll-retention Map is 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>
- `cd neode-ui && npm run test` exits 0 - `cd neode-ui && npm run type-check` exits 0 - `cd neode-ui && npm run build` exits 0 and the new symbols appear in `web/dist/neode-ui/assets` - The human-verify checkpoint is approved against archi-dev on the :8100 preview

<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>
Create `.planning/phases/02-ui-performance/02-02-SUMMARY.md` when done. It MUST record: the tracer tab actually chosen and why; the final `DashboardRouterView.vue` template shape; every side effect moved to `onActivated`/`onDeactivated` versus left in `onMounted`, with reasons; and the `persist` decision made for each new cache key. Plans 02-04, 02-05, 02-06 and 02-07 read all four from this file.