21 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 | 05 | execute | 4 |
|
|
true |
|
|
Purpose: PERF-02. Mesh is the heaviest main tab in the app — 2,651 lines, a live D3 force
simulation and a Leaflet map — and RESEARCH.md's code-level scan found its onMounted
already correctly parallel (await Promise.all([...]) across six groups) but nothing
cached, so all six re-run on every tab entry. It is also the tab D-03 singles out for
bounded memory. It is planned separately from the other tabs purely on context cost: its
size exceeds what a shared task can hold.
Output: six cached fetch groups with per-dataset TTLs, and a graph and map that survive deactivation without leaking, freezing, or mis-rendering.
<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/phases/02-ui-performance/02-02-SUMMARY.md @.planning/phases/02-ui-performance/02-04-SUMMARY.md @.planning/codebase/CONVENTIONS.md @CLAUDE.md Task 1: Cache the six Mesh fetch groups without serializing them neode-ui/src/views/Mesh.vue, neode-ui/src/stores/mesh.ts, neode-ui/src/stores/transport.ts, neode-ui/src/views/__tests__/meshTabCache.test.ts - `neode-ui/src/views/Mesh.vue` — 2,651 lines; do NOT read it whole. Grep for `onMounted`, `onActivated`, `refreshAll`, `fetchStatus`, `refreshFederationNodes`, `refreshSelfOnion`, `refreshSelfDid`, `refreshContacts` and `useCachedResource`, then read only those regions. The `onMounted` body is `await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()])`. - `neode-ui/src/stores/mesh.ts` — read `refreshAll()` and whatever it fans out to; decide whether the cache belongs behind the store action (shared by every consumer) or in the view. - `neode-ui/src/stores/transport.ts` — read `fetchStatus()` for the same decision. - `neode-ui/src/views/Cloud.vue` lines 505-530 and 945-970 — the in-repo reference for resource definition and keep-last-value error handling. - `neode-ui/src/composables/useCachedResource.ts` — the options contract and returned surface, including the `onActivated` revalidation added by plan 02-02. - `neode-ui/src/api/rpc-client.ts` — the `dedup: true` option to pass on every fetcher. - `.planning/phases/02-ui-performance/02-04-SUMMARY.md` — the side-effect bucket table for `Mesh.vue`, so this task does not re-litigate placements already decided. - `.planning/phases/02-ui-performance/02-FINDINGS.md` — Mesh's measured revisit RPC count and primary cause. - Mounting the Mesh tab, deactivating, and reactivating inside the TTL issues zero new RPCs across all six groups - Reactivating after the TTL issues exactly one revalidation per stale group, with the previous graph and peer list still rendered - A cold load still issues all six groups concurrently — the recorded start times overlap rather than forming a chain - A rejected refresh in one group leaves the other five unaffected and leaves that group's last known data rendered - Peer reachability data has a shorter TTL than near-static identity data, so a resumed tab does not show a long-stale reachability state Convert each of the six fetch groups to a `useCachedResource` entry, following the `Cloud.vue` pattern. Place each cache at the level that serves the most consumers: `mesh.refreshAll()` and `transport.fetchStatus()` are store actions with other callers, so cache behind the store action; the four view-local refreshers (`refreshFederationNodes`, `refreshSelfOnion`, `refreshSelfDid`, `refreshContacts`) cache in the view. Record the placement decision per group in the SUMMARY.Set `ttlMs` explicitly per group rather than taking the default, using the D-06
discretion. Peer and transport state move fast and warrant a short TTL of around
10000 ms; federation node lists sit at the 30000 ms default; this node's own onion
address and DID are effectively static and warrant a long TTL of 300000 ms or more.
Choose a value per group and give the reason in the SUMMARY.
Set `persist` explicitly per group. This node's own DID and onion address, and any
peer identity payload (peer DIDs, pubkeys, onion addresses, contact records), are
memory-only: declare `persist: false`. Non-identity aggregate counts and transport
status may persist. This is the sessionStorage privacy prohibition in this plan's
`must_haves`, and it is not negotiable against a shorter first paint.
Pass `dedup: true` on every underlying `rpcClient.call`.
Keep the fan-out concurrent, per D-13: waterfalls are fixed client-side by
parallelizing plus rpc-client dedup. The `onMounted` `Promise.all` must stay a single
awaited group; converting each call into a separately-awaited cached refresh would
turn an already-parallel load into the exact waterfall this phase exists to remove. If
a group's refresh must be kicked explicitly, use `immediate: false` on the resource and
call `refresh()` inside the same `Promise.allSettled` array, as `Cloud.vue` does with
`peersResource`. Prefer `allSettled` over `all` so one failing group does not suppress
the other five. D-13 reserves a new aggregate endpoint for a screen needing three or
more genuinely dependent calls; Mesh's six groups are independent, so none is
warranted here. Should one become necessary, D-12 bounds it to an additive new handler
with no refactor of existing handlers and nothing touching the orchestrator — stop for
a checkpoint before any `core/` change, since no backend work is in this plan's scope.
Wire `RefreshIndicator` (from plan 02-02) into the Mesh header, driven by whether any
of the six groups is in `refreshing` — the subtle in-header signal D-05 specifies, not
a stale-age badge. Peer reachability is a liveness-critical figure:
when the tab is re-entered and the reachability group is stale, the indicator must be
visible while it revalidates, so a resumed tab never presents a frozen reachability
state as current.
Error handling follows D-07: keep the last known value, set the view's existing error
ref for a banner, raise no toast.
Create `neode-ui/src/views/__tests__/meshTabCache.test.ts` covering the five behaviors
above. Mock the store actions and the RPC client with `vi.fn()` fetchers, mount inside
a `<KeepAlive>`, and assert call counts per group across a deactivate/reactivate
cycle with fake timers. For the concurrency assertion, record invocation timestamps
and assert the six starts overlap rather than forming a chain.
On deactivate: stop the D3 force simulation rather than destroying it, cancel any
pending animation-frame handle the view owns, and remove any window or resize listener
the view registered. On activate: re-add the listener exactly once (clearing any prior
handle first, since `onActivated` also fires on first mount), call the Leaflet map's
size-invalidation on `nextTick` so a map laid out while hidden re-tiles at its real
size, and restart the simulation only when the underlying graph data changed while
away. Restarting unconditionally would replay the layout animation on every tab entry,
which reads as the sluggishness this phase is removing.
Do not destroy and rebuild either context on deactivate. Rebuilding is what today's
remount already does and is the cost being eliminated; keeping one instance for the
session is the point.
Add the six behaviors above to `meshTabCache.test.ts`. Stub `d3` and the Leaflet
binding at the module boundary with `vi.mock` so the assertions are on the calls made
(simulation stop and restart, size-invalidation, listener add and remove, constructor
invocation counts) rather than on real rendering, which jsdom cannot do.
Instance-count growth across many tab cycles is a heap property that a unit test
cannot settle. Record the design in the SUMMARY so plan 02-08 can check it on
archi-dev-box with the browser's memory tooling.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| peer node identity data → local browser storage | Peer DIDs, pubkeys, onion addresses and contact records cross into this node's browser cache |
| resident graphics context → node resources | A live D3 simulation and Leaflet map held for the session against low-power fleet hardware |
| cached peer state → operator's belief about reachability | A settled graph shows the mesh as it was when the tab was last visible |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-02-01 | Information Disclosure | Peer identity payloads and this node's own DID/onion written to sessionStorage by the default persist: true |
high | mitigate | Task 1 requires persist: false on every group carrying a DID, onion address, pubkey or contact record; only non-identity aggregates may persist |
| T-02-03 | Denial of Service | Resident D3 simulation and Leaflet map on low-power fleet hardware | medium | mitigate | Task 2 stops the simulation and cancels animation frames on deactivate, constructs exactly one of each per session, and KEEP_ALIVE_MAX evicts Mesh under pressure; heap growth is checked on device in plan 02-08 |
| T-02-13 | Spoofing | A frozen peer-reachability state rendered as current after a resumed tab | high | mitigate | Task 1 gives reachability the shortest TTL of the six groups and requires the RefreshIndicator to be visible while it revalidates on re-entry |
| T-02-16 | Denial of Service | Converting the parallel six-group fan-out into a serial chain of awaited refreshes | medium | mitigate | Task 1 forbids per-group awaiting, requires immediate: false plus a single Promise.allSettled, and asserts overlapping start times in test |
| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope; d3 and the Leaflet bindings are already direct dependencies of neode-ui. 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
Created or changed by this plan — new API, not drift:
neode-ui/src/views/__tests__/meshTabCache.test.ts- Cached-resource entries behind
stores/mesh.tsrefreshAll()andstores/transport.tsfetchStatus() - Mesh cache keys for federation nodes, self onion, self DID and contacts
onDeactivated/ extendedonActivatedhandling inMesh.vuefor the D3 simulation and Leaflet map
Created elsewhere in Phase 02: shouldKeepAlive(), KEEP_ALIVE_PATHS, KEEP_ALIVE_MAX,
DashboardRouterView.vue, RefreshIndicator.vue, resources.clearAll(),
useCachedResource.test.ts, keepAliveTabs.test.ts, keepAliveLifecycle.test.ts,
secondaryScreenCache.test.ts, resourcesClear.test.ts,
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; surfaced here for human review. Resolved in substance by this plan'smust_haves.truths, with the heap-growth truth carried as averification: backstopmarker — a jsdom test can prove one constructor call but not flat heap usage across twenty cycles, which is checked on hardware in plan 02-08. - Open: whether
mesh.refreshAll()andtransport.fetchStatus()have consumers outsideMesh.vueis not settled from the route table alone; Task 1 reads both stores and records the cache-placement decision per group in the SUMMARY. - Open: whether restarting the D3 simulation on re-entry is even desirable depends on whether the graph data changed while away. Task 2 makes the restart conditional and records the condition; if the settled-layout behavior reads wrong on device, plan 02-08's walkthrough is where that surfaces.
- Note: RESEARCH.md is explicit that
Mesh.vue'sonMountedis already correctly parallel and must not be "fixed". This plan converts what those calls read from, not the order they run in. </assumptions_and_flagged_items>
<success_criteria>
- A revisit to Mesh inside the TTL issues no RPC and paints the previous graph and peer list immediately
- A stale revisit revalidates each stale group exactly once, visibly, without clearing the screen
- The cold-load fan-out is still concurrent
- No peer or self identity payload is written to sessionStorage
- One D3 simulation and one Leaflet map exist per session, quiesced off screen and repaired on return </success_criteria>