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

28 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 03 execute 2
02-01
neode-ui/src/stores/resources.ts
neode-ui/src/stores/auth.ts
neode-ui/src/stores/__tests__/resourcesClear.test.ts
neode-ui/src/views/AppDetails.vue
neode-ui/src/views/MarketplaceAppDetails.vue
neode-ui/src/views/__tests__/secondaryScreenCache.test.ts
neode-ui/src/views/CloudFolder.vue
neode-ui/src/views/server/OpenWrtGateway.vue
true
PERF-03
truths prohibitions artifacts key_links
Opening a secondary screen for an item already opened this session paints its content immediately from cache, with no blocking full reload
A repeat open of the same secondary screen inside the TTL issues no new RPC for the cached dataset
A repeat open after the TTL keeps the previous content on screen while exactly one background revalidation runs
Opening secondary screen for item B never renders item A's data — each per-item cache key embeds the item identifier
Secondary screens are not instance-cached: their component mounts fresh on every visit (D-04)
Independent loads inside a secondary screen run concurrently rather than one awaiting the next
Logging out clears every cached resource from memory and from sessionStorage, so the next session starts empty
Large or peer-sourced payloads (file listings, media metadata) are held in memory only and are never written to sessionStorage (D-08)
A destructive action taken on a secondary screen (uninstall, stop, remove) invalidates that screen's cached entry before the screen re-renders
statement verification
First opens of a never-before-visited secondary screen may still show a loading state; only repeat opens are required to be instant backstop
MUST NOT let one item's or one identity's cached data be served under another — every per-item cache key is fully qualified by the item identifier, and the whole cache is cleared on logout
MUST NOT persist peer-sourced content (other nodes' file listings, media metadata) to sessionStorage
MUST NOT display a stale success or health state after a destructive action — such actions invalidate their screen's cache before rendering
path provides exports
neode-ui/src/stores/resources.ts clearAll() — drops every cached entry from memory and every resource: snapshot from sessionStorage
clearAll
path provides
neode-ui/src/stores/auth.ts logout() purges the resource cache before the session ends
path provides
neode-ui/src/views/__tests__/secondaryScreenCache.test.ts Fetcher call-count assertions proving cache-on-repeat-open and per-item key isolation
path provides
neode-ui/src/stores/__tests__/resourcesClear.test.ts Coverage for clearAll and the logout purge
from to via pattern
neode-ui/src/stores/auth.ts neode-ui/src/stores/resources.ts logout() calls clearAll() so no cached payload outlives the session clearAll
from to via pattern
neode-ui/src/views/AppDetails.vue neode-ui/src/composables/useCachedResource.ts per-item keyed cached resources keyed by the route's app id useCachedResource
from to via pattern
neode-ui/src/views/MarketplaceAppDetails.vue neode-ui/src/composables/useCachedResource.ts per-item keyed cached resource keyed by the route's marketplace app id useCachedResource
Make secondary screens — the screens reached from a tab's main page — open without a blocking reload and paint instantly on repeat visits, using the existing stale-while-revalidate hook keyed per item, with no component-instance caching.

Purpose: PERF-03. D-04 is explicit that secondary screens get useCachedResource keyed per item but no <KeepAlive> — item counts are unbounded and an instance cache would bloat. The data cache alone delivers the instant repeat open.

This plan runs in parallel with the tracer (02-02): it touches a disjoint set of files and it consumes useCachedResource exactly as its eight existing callers already do, so it does not depend on the tracer's architecture landing first. It does own the cache-lifetime safety work — the logout purge — that every other plan's caching relies on.

Output: a purge-on-logout cache lifecycle, and the findings-named secondary screens converted to keyed cached resources with their independent loads parallelized.

<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: Cache lifetime — purge every cached resource on logout neode-ui/src/stores/resources.ts, neode-ui/src/stores/auth.ts, neode-ui/src/stores/__tests__/resourcesClear.test.ts - `neode-ui/src/stores/resources.ts` — the whole file. Note `SNAPSHOT_PREFIX` (`resource:`), the `entries` reactive Map, the `inflight`, `revalidators` and `invalidateTimers` Maps, and the existing single-key `evict(key)` at line ~156 whose shape `clearAll` mirrors. The store's return object at the end of `defineStore` is what must gain the new export. - `neode-ui/src/stores/auth.ts` — the whole file. `logout()` is at line ~76 and calls `rpcClient.logout()`. This is the single choke point: `Dashboard.vue`'s `handleLogout`, `views/settings/AccountSection.vue`'s `handleLogout` and `Login.vue` all route through it via `stores/app.ts`'s `logout: auth.logout` re-export. - `neode-ui/src/stores/app.ts` line ~50 — confirms the re-export, so no additional call site needs editing. - `neode-ui/src/stores/__tests__/` — list it and read one existing store test for the Pinia `setActivePinia(createPinia())` setup convention. - After `clearAll()`, `entries.size` is 0 - After `clearAll()`, no sessionStorage key beginning with `resource:` remains, and keys not beginning with that prefix are untouched - `clearAll()` cancels any pending invalidate timers and drops the in-flight and revalidator maps, so a resolving fetch from the old session cannot repopulate the cache - `clearAll()` does not throw when sessionStorage is unavailable or throws on access - `auth.logout()` clears the cache even when the backend `auth.logout` RPC rejects Add `clearAll()` to `neode-ui/src/stores/resources.ts` and include it in the store's returned object alongside the existing `evict`. It must: clear the `entries` Map; clear the `inflight`, `revalidators` and `invalidateTimers` Maps, calling `clearTimeout` on each pending timer first; and remove every sessionStorage key beginning with `SNAPSHOT_PREFIX`. Iterate the sessionStorage keys into an array before removing, so the live index does not shift mid-loop, and wrap the whole storage section in the same defensive try/catch the file already uses around `sessionStorage` access.
In `neode-ui/src/stores/auth.ts`, call `useResourcesStore().clearAll()` from
`logout()`. Place the call so it runs whether or not the `rpcClient.logout()` RPC
succeeds — a failed server-side logout must still leave no cached payload behind
locally. Do not add a second purge call at any other site; `auth.logout()` is the
choke point every logout path already funnels through.

Write `neode-ui/src/stores/__tests__/resourcesClear.test.ts` covering the five
behaviors above. For the sessionStorage assertions, seed both a `resource:`-prefixed
key and an unrelated key and assert only the former is removed. For the auth test,
mock `rpcClient.logout` to reject and assert the cache is still empty afterwards.
cd neode-ui && npm run test -- src/stores/__tests__/resourcesClear.test.ts && npm run type-check - `neode-ui/src/stores/resources.ts` exports `clearAll` from its store return object - `neode-ui/src/stores/auth.ts` calls `clearAll` inside `logout()`: `grep -c "clearAll" neode-ui/src/stores/auth.ts` is at least 1 - `npm run test -- src/stores/__tests__/resourcesClear.test.ts` exits 0 with all five behaviors covered - A test asserts that a sessionStorage key not beginning with `resource:` survives `clearAll()` - A test asserts the cache is empty after `logout()` when the logout RPC rejects - `npm run type-check` exits 0 No cached resource — in memory or in sessionStorage — outlives a logout, and the guarantee is pinned by tests. Task 2: App detail screens open instantly on repeat visits neode-ui/src/views/AppDetails.vue, neode-ui/src/views/MarketplaceAppDetails.vue, neode-ui/src/views/__tests__/secondaryScreenCache.test.ts - `neode-ui/src/views/AppDetails.vue` — the whole file is 386 lines. Read `onMounted` at line ~204 (`loadBitcoinSync(); loadCredentials()` — already fire-and-forget, so not a waterfall), both loader bodies, how the route's `:id` param reaches the component, and the existing error handling. - `neode-ui/src/views/MarketplaceAppDetails.vue` — 700 lines. Grep for `onMounted` (line ~525), `rpcClient`, `fetch(` and `await` first, then read only the loader region and the `onMounted` block. Do not read the whole file. - `neode-ui/src/views/Cloud.vue` lines 505-530 and 945-970 — the in-repo reference for a cached-resource definition and for the keep-last-value error handling to mirror. - `neode-ui/src/composables/useCachedResource.ts` — the options contract (`key`, `fetcher`, `ttlMs`, `persist`, `revalidateOnFocus`, `immediate`) and the returned `entry` / `data` / `loadState` / `error` / `refresh` / `invalidate` surface. - `neode-ui/src/api/rpc-client.ts` — the `dedup: true` option (line ~16, applied at line ~95) to pass on every newly-parallelized call so concurrent identical calls collapse. - `neode-ui/src/views/__tests__/CloudPeersRefresh.test.ts` — the Vitest + `@vue/test-utils` + Pinia + `vi.mock('@/api/rpc-client')` pattern to follow. - `.planning/phases/02-ui-performance/02-FINDINGS.md` — the measured revisit RPC count and primary cause for each of these two screens; if the findings classify one as already fast, leave it alone per D-02 and record that in the SUMMARY. - Mounting AppDetails for app id `alpha`, unmounting, and remounting for `alpha` inside the TTL calls each fetcher exactly once in total - Mounting AppDetails for `alpha` then for `beta` calls each fetcher twice in total and renders `beta`'s data, never `alpha`'s - Remounting for `alpha` after the TTL lapses calls the fetcher once more while the previously cached data is already rendered on the first frame - A rejected refresh leaves the previously rendered data in the DOM and sets the view's error ref - The independent loads in a single mount are issued concurrently, not one after the other Convert both app-detail screens to keyed cached resources, one resource per logical dataset, following the `Cloud.vue` pattern.
Cache keys embed the item identifier so two items can never collide: use
`app-details:${appId}` shaped keys — for `AppDetails.vue` that means
`app-details:bitcoin-sync:${appId}` and `app-details:credentials:${appId}`, and for
`MarketplaceAppDetails.vue` a key of the same shape built from its own route param.
The key must be computed from the current route param at hook-call time, and the
component must re-key when the param changes (these screens are not instance-cached,
so a param change normally remounts them — confirm that by reading how the route is
declared, and if the router reuses the instance across an id change, drive the
resource through a `watch` on the id that calls `refresh()` against the new key).

Set `ttlMs` explicitly per resource rather than taking the default. Credentials and
install/health state move fast enough to warrant the 30000 ms default; near-static
catalog-shaped metadata can take a longer value. Set `persist` explicitly too:
anything carrying credential material, DIDs, wallet figures or transaction history
is `persist: false` and stays memory-only, per D-08 and the privacy prohibition in
this plan's `must_haves`. Record each key's TTL and persist choice in the SUMMARY.

Pass `dedup: true` on the underlying `rpcClient.call` for each fetcher so two mounted
consumers of the same method collapse into one request.

Where a screen awaits independent loads sequentially, replace the chain with a single
`await Promise.allSettled([...])` — `allSettled` rather than `all` so one failing
load does not suppress the others, matching the existing per-loader error handling
where each loader owns its own loading ref. This is D-13's client-side fix: waterfalls
are removed by parallelizing plus rpc-client dedup, and a new aggregate endpoint is
reserved for a screen that genuinely needs three or more dependent calls. If one of
these screens turns out to need such an endpoint, D-12 bounds it: additive only, a new
handler alongside the existing ones, no refactor of an existing handler and nothing
touching the orchestrator — and it stops for a checkpoint before any `core/` change,
since no backend work is otherwise in this plan's scope. Verify independence before
parallelizing:
a load that consumes another's result stays sequential. `AppDetails.vue`'s
`onMounted` already fires both loaders without awaiting them, so it is already
effectively parallel — do not "fix" it into something slower, and say so in the
SUMMARY.

Error handling follows D-07: a failed background refresh keeps the last known value
on screen and sets the view's existing error ref for a banner. No toast.

Invalidate before re-render after a destructive action: wherever these screens
trigger an uninstall, stop, or removal, call the affected resource's `invalidate()`
(or `refresh()`) as part of the action's completion path, so the screen cannot show a
stale healthy state for something that no longer exists.

Create `neode-ui/src/views/__tests__/secondaryScreenCache.test.ts` covering the five
behaviors above with `vi.fn()` fetchers and explicit call-count assertions. Use fake
timers to cross the TTL boundary. The per-item isolation test is the important one —
assert on rendered content, not only on call counts.
cd neode-ui && npm run test -- src/views/__tests__/secondaryScreenCache.test.ts && npm run type-check - `neode-ui/src/views/AppDetails.vue` and `neode-ui/src/views/MarketplaceAppDetails.vue` each import and call `useCachedResource` - Every cache key introduced contains the route item id — a test asserts that mounting for `alpha` then `beta` produces two distinct keys and renders `beta`'s data - `npm run test -- src/views/__tests__/secondaryScreenCache.test.ts` exits 0 with all five behaviors covered - A repeat mount inside the TTL records exactly one total fetcher call per resource - A repeat mount after the TTL records exactly two, with the cached data present on the first rendered frame - Every fetcher passes `dedup: true` to `rpcClient.call` - Every resource carrying credentials, DIDs, wallet figures or transaction history is declared `persist: false` - `npm run test` (full suite) exits 0 and `npm run type-check` exits 0 - The SUMMARY lists every key with its TTL, its persist choice, and the reason Both app-detail screens paint from cache on a repeat open, never cross item data, revalidate exactly once when stale, and hold nothing sensitive in sessionStorage. Task 3: The remaining findings-named secondary screens neode-ui/src/views/CloudFolder.vue, neode-ui/src/views/server/OpenWrtGateway.vue, neode-ui/src/views/__tests__/secondaryScreenCache.test.ts - `.planning/phases/02-ui-performance/02-FINDINGS.md` — the authoritative list. Only screens this doc classifies as `remount storm`, `serial RPC waterfall` or `uncached fetch` are in scope; anything it classifies `already fast` is left alone per D-02. - `.planning/phases/02-ui-performance/02-PERF-BASELINE.json` — the `revisitRpcCalls` array per screen, which shows whether calls overlapped or ran one after another. - `neode-ui/src/views/CloudFolder.vue` — it has no `onMounted`; data arrives through `cloudStore` and two `watch` blocks (line ~188 on `cloudStore.currentPath`, line ~313 on `[useNativeUI, section, routeFolderPath]`). Read both watches and how `cloudStore` loads before deciding whether the cache belongs in the view or behind the store's loader. - `neode-ui/src/views/server/OpenWrtGateway.vue` — 909 lines; grep for `onMounted` (line ~374, `onMounted(() => load())`) and `load(` first, then read only the `load()` implementation and the note at line ~204 about reconnecting. - `neode-ui/src/stores/cloud.ts` — if the cache belongs behind the store loader rather than in `CloudFolder.vue`, this is where it goes; read its loading actions first. - `neode-ui/src/views/AppDetails.vue` as left by Task 2 — the in-plan precedent for key shape, TTL and persist choices. Convert the remaining secondary screens that `02-FINDINGS.md` names, in the ranked order the findings give, applying the same treatment established in Task 2: a keyed cached resource per logical dataset with the item identifier in the key, an explicit TTL, an explicit `persist` decision, `dedup: true` on the underlying call, keep-last- value error handling with no toast, and `invalidate()` on any destructive action.
Candidate set from the route table, gated on what the findings actually name:
`cloud/:folderId` (`CloudFolder.vue`), `server/openwrt`
(`views/server/OpenWrtGateway.vue`), `cloud/peers/:peerId?` (`PeerFiles.vue`),
`apps/lnd/channels` (`views/apps/LightningChannels.vue`), `goals/:goalId`
(`GoalDetail.vue`) and `app-session/:appId` (`AppSession.vue`). `PeerFiles.vue`,
`Credentials.vue`, `Federation.vue` and `Monitoring.vue` already consume
`useCachedResource`; for those, verify the key embeds the item id and that the persist
choice is right, and change nothing else.

Two payload classes are memory-only regardless of what the findings say: file
listings and media metadata (large, per D-08) and any peer-sourced content — another
node's file listing or media index must not be written to this node's sessionStorage.
Declare `persist: false` for both and note it in the SUMMARY.

For `CloudFolder.vue`, decide where the cache belongs before writing code. Its data
flows through `cloudStore` and two watches, not through a mount hook. If several
views share the same store loader, put the cached resource behind the store action so
every consumer benefits, rather than wrapping the view's own reads and leaving the
store uncached. Record the decision and its reason in the SUMMARY.

Extend `neode-ui/src/views/__tests__/secondaryScreenCache.test.ts` with a repeat-open
call-count assertion for each screen converted here.

Scope guard: if `02-FINDINGS.md` names more than the four screens this plan's
`files_modified` covers, convert them in ranked order until the plan's context budget
is reached, then stop and report the remainder to the orchestrator as an unplanned-item
gap with the surface names and their measured causes. Do not silently skip a named
screen and do not quietly narrow the findings list.

`neode-ui/src/views/ContainerAppDetails.vue` is out of scope. `02-FINDINGS.md`
`## Corrections to Prior Research` records whether it has any importer or route entry;
if it has none, it is unreachable code and converting it would deliver nothing, even
though `02-RESEARCH.md` names it as a confirmed waterfall. Do not spend effort on it,
and do not delete it in this plan.
cd neode-ui && npm run test -- src/views/__tests__/secondaryScreenCache.test.ts && npm run test && npm run type-check - Every secondary screen that `02-FINDINGS.md` classifies as slow either appears in this plan's converted set or is reported to the orchestrator as an unplanned-item gap — none is silently skipped - Each converted screen imports `useCachedResource` and its keys embed the route item id - Each converted screen has a repeat-open call-count assertion in `secondaryScreenCache.test.ts` - File-listing and media-metadata resources are declared `persist: false` - No file named `ContainerAppDetails.vue` appears in this plan's diff: `git diff --name-only HEAD -- neode-ui/src/views/ContainerAppDetails.vue | wc -l` prints 0 - `npm run test` exits 0 and `npm run type-check` exits 0 - `npm run build` exits 0 and the new cache keys appear in the built bundle: `grep -rl "app-details:" web/dist/neode-ui/assets | head -1` prints a file Every secondary screen the profiling pass named as slow opens from cache on a repeat visit, with per-item keys, no sensitive or peer-sourced payload in sessionStorage, and a call-count test pinning each one.

<threat_model>

Trust Boundaries

Boundary Description
peer node → this node's browser storage Peer-sourced file listings and media metadata cross from another operator's node into local storage
authenticated session → sessionStorage Cached per-item payloads survive navigation and reload within the browser tab
item A's cache entry → item B's render A key-construction mistake serves one item's data under another's screen

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-02-02 Information Disclosure Cached entries surviving a logout or identity switch high mitigate Task 1 adds resources.clearAll() and calls it from auth.logout() on both the success and failure paths, dropping memory entries and every resource: sessionStorage snapshot
T-02-01 Information Disclosure useCachedResource default persist: true on credential, DID and wallet payloads high mitigate Tasks 2 and 3 require an explicit per-resource persist decision; credential material, DIDs, wallet figures and transaction history are persist: false (memory-only)
T-02-10 Information Disclosure Peer-sourced content written to local sessionStorage high mitigate Task 3 declares file listings and media metadata persist: false unconditionally, independent of the findings classification
T-02-11 Spoofing Per-item cache key collision serving item A's data under item B medium mitigate Every key embeds the route item id; Task 2's per-item isolation test asserts on rendered content, not only on fetcher call counts
T-02-12 Tampering A stale cached entry masking the result of a destructive action medium mitigate Tasks 2 and 3 require invalidate() on the completion path of every uninstall, stop or removal action on a converted screen
T-02-SC Tampering npm/pip/cargo installs high mitigate No package-manager installs are in scope; Promise.allSettled is a language built-in and useCachedResource already ships in this repo. A task that finds it needs a new dependency 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/stores/resources.ts — new export clearAll()
  • neode-ui/src/stores/__tests__/resourcesClear.test.ts
  • neode-ui/src/views/__tests__/secondaryScreenCache.test.ts
  • Cache-key family introduced: app-details:<dataset>:<appId> and the same shape for the other converted secondary screens

Created elsewhere in Phase 02: neode-ui/src/views/dashboard/keepAliveRoutes.ts (shouldKeepAlive, KEEP_ALIVE_PATHS, KEEP_ALIVE_MAX), neode-ui/src/views/dashboard/DashboardRouterView.vue, neode-ui/src/components/RefreshIndicator.vue, neode-ui/src/composables/__tests__/useCachedResource.test.ts, neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts, 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-03 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; the first-visit-may-still-load boundary is carried as a verification: backstop marker because CONTEXT.md D-11 states it as an allowance rather than as an assertable check. The probe row itself stays unresolved and is surfaced here for human review.
  • FA-B (correction to 02-RESEARCH.md): neode-ui/src/views/ContainerAppDetails.vue appears to be dead code — grep -rn "ContainerAppDetails" neode-ui/src returns only a self-referential comment inside the file, and it has no entry in neode-ui/src/router/index.ts (verified 2026-07-30). RESEARCH.md names it as the phase's confirmed serial-waterfall fix target. Plan 02-01 Task 3 re-runs the grep and records the verdict; this plan excludes the file either way and sources its waterfall targets from measured revisitRpcCalls instead.
  • Open: CloudFolder.vue loads through cloudStore and two watches rather than a mount hook, so whether the cache belongs in the view or behind the store action is decided during Task 3 and recorded in the SUMMARY.
  • Open: whether the router reuses a detail component instance across an id change on these routes is not settled from the route table alone. Task 2 requires it to be confirmed by reading the route declaration, with a watch-driven re-key as the fallback. </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 cache keys appear in `web/dist/neode-ui/assets` - Every secondary screen named slow by `02-FINDINGS.md` is either converted here or reported as an unplanned-item gap

<success_criteria>

  • Repeat opens of a secondary screen paint from cache with no blocking reload and no new RPC inside the TTL
  • Per-item keys prevent any cross-item data bleed, proven by a rendered-content assertion
  • Secondary screens still mount fresh — nothing here instance-caches them
  • Logout leaves no cached payload in memory or in sessionStorage
  • No sensitive or peer-sourced payload is written to sessionStorage </success_criteria>
Create `.planning/phases/02-ui-performance/02-03-SUMMARY.md` when done. It MUST record: every cache key introduced with its TTL and persist choice and the reason; which screens the findings named and which of those were converted here versus reported as a gap; the `CloudFolder.vue` cache-placement decision; and the verdict on whether `ContainerAppDetails.vue` is reachable.