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 |
|
|
true |
|
|
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.
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.
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.
<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 exportclearAll()neode-ui/src/stores/__tests__/resourcesClear.test.tsneode-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'smust_haves.truths; the first-visit-may-still-load boundary is carried as averification: backstopmarker 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.vueappears to be dead code —grep -rn "ContainerAppDetails" neode-ui/srcreturns only a self-referential comment inside the file, and it has no entry inneode-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 measuredrevisitRpcCallsinstead. - Open:
CloudFolder.vueloads throughcloudStoreand 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>
<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>