Files

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
02-04
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
true
PERF-02
truths prohibitions artifacts key_links
Returning to the Mesh tab within the TTL issues no RPC for any of its six fetch groups
Returning to the Mesh tab after the TTL keeps the peer graph and map on screen while exactly one background revalidation runs per stale group
The six fetch groups still run concurrently on a cold load — the conversion does not serialize them
Peer reachability and sync status shown on the Mesh tab are revalidated on re-entry, never left frozen at their last-visible values
The D3 force simulation stops while the Mesh tab is off screen and resumes when it is re-entered
The Leaflet map renders correctly after re-entry rather than showing an unsized or partially tiled canvas
Repeatedly entering and leaving the Mesh tab creates one D3 simulation and one Leaflet map instance in total, not one per visit
statement verification
Cycling the Mesh tab twenty times leaves heap usage flat rather than growing monotonically 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
neode-ui/src/stores/mesh.ts Mesh refresh path backed by a cached resource so every consumer shares one entry
path provides
neode-ui/src/stores/transport.ts Transport status backed by a cached resource
path provides
neode-ui/src/views/__tests__/meshTabCache.test.ts Call-count assertions per fetch group plus simulation and map lifecycle assertions
from to via pattern
neode-ui/src/views/Mesh.vue neode-ui/src/composables/useCachedResource.ts each of the six fetch groups becomes a keyed cached resource useCachedResource
from to via pattern
neode-ui/src/views/Mesh.vue vue onActivated / onDeactivated the D3 simulation is stopped on deactivate and the Leaflet map is re-sized on activate onDeactivated
Cache the Mesh tab's six uncached fetch groups and make its D3 force graph and Leaflet map correct and bounded now that the tab's component instance survives tab switches.

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.
cd neode-ui && npm run test -- src/views/__tests__/meshTabCache.test.ts && npm run test && npm run type-check - `neode-ui/src/views/Mesh.vue` imports `useCachedResource`, and `neode-ui/src/stores/mesh.ts` and `neode-ui/src/stores/transport.ts` each route their refresh through a cached entry - `npm run test -- src/views/__tests__/meshTabCache.test.ts` exits 0 with all five behaviors covered - The reactivate-inside-TTL test asserts zero additional fetcher calls across all six groups - The concurrency test asserts the six cold-load fetchers overlap in time - Every group carrying this node's DID or onion address, or any peer identity payload, is declared `persist: false` - Every fetcher passes `dedup: true` - `neode-ui/src/views/Mesh.vue` renders `RefreshIndicator` - `npm run test` exits 0 and `npm run type-check` exits 0 - The SUMMARY records, per group: cache placement, TTL, persist choice, and reason All six Mesh fetch groups are cached with deliberate TTLs and persist choices, a revisit inside the TTL issues no RPC, the cold-load fan-out is still concurrent, and no identity payload reaches sessionStorage. Task 2: Bound the D3 simulation and Leaflet map across deactivation neode-ui/src/views/Mesh.vue, neode-ui/src/views/__tests__/meshTabCache.test.ts - `neode-ui/src/views/Mesh.vue` — grep for `d3`, `forceSimulation`, `simulation`, `requestAnimationFrame`, `LMap`, `leaflet`, `invalidateSize`, `ResizeObserver` and `addEventListener`, then read only those regions. - `neode-ui/package.json` — confirms `d3` and the Leaflet bindings are direct dependencies; no new package is needed here. - `.planning/phases/02-ui-performance/02-04-SUMMARY.md` — the side-effect bucket table for `Mesh.vue` from the lifecycle audit; this task extends it to the graphics contexts specifically. - `.planning/phases/02-ui-performance/02-RESEARCH.md` pitfall 6 — the memory-growth failure mode on low-power fleet nodes that this task prevents. - `.planning/phases/02-ui-performance/02-CONTEXT.md` — D-03 keeps Mesh alive but requires bounded memory. - Deactivating the Mesh tab stops the D3 force simulation, so its tick callback is not invoked while the tab is off screen - Reactivating restarts the simulation only if the graph data changed while away; otherwise the graph is left at its settled layout rather than re-heating and visibly re-animating - Deactivating cancels any pending animation-frame callback the view owns - Reactivating calls the Leaflet map's size-invalidation so the map paints correctly after being laid out while hidden - Entering and leaving the tab three times constructs exactly one simulation and one map instance - Any window or resize listener the view registers is removed on deactivate and re-added exactly once on activate Extend `Mesh.vue`'s activate/deactivate handling (established in plan 02-04) to cover its two graphics contexts. Under an instance cache these are constructed once and then live for the session, which is exactly what D-03 wants — but only if they are quiesced while off screen and repaired on return.
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.
cd neode-ui && npm run test -- src/views/__tests__/meshTabCache.test.ts && npm run test && npm run type-check && npm run build - `npm run test -- src/views/__tests__/meshTabCache.test.ts` exits 0 with all six behaviors covered - A test asserts the simulation's stop call happens on deactivate and its tick callback is not invoked while deactivated - A test asserts three enter/leave cycles construct exactly one simulation and one map - A test asserts the Leaflet size-invalidation is called on activate - A test asserts a window or resize listener is added exactly once across two consecutive activations - `neode-ui/src/views/Mesh.vue` references `onDeactivated` - `npm run test` exits 0, `npm run type-check` exits 0, `npm run build` exits 0 - The SUMMARY records what runs on deactivate, what runs on activate, and the condition under which the simulation restarts The Mesh graph and map are constructed once per session, quiesced while off screen, repaired on return without replaying their entry animation, and the heap check is handed off to the on-device plan with a documented design.

<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.ts refreshAll() and stores/transport.ts fetchStatus()
  • Mesh cache keys for federation nodes, self onion, self DID and contacts
  • onDeactivated / extended onActivated handling in Mesh.vue for 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's must_haves.truths, with the heap-growth truth carried as a verification: backstop marker — 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() and transport.fetchStatus() have consumers outside Mesh.vue is 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's onMounted is 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>
- `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 - Mesh revisit RPC count is zero inside the TTL, asserted in `meshTabCache.test.ts`

<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>
Create `.planning/phases/02-ui-performance/02-05-SUMMARY.md` when done. It MUST record: per fetch group, its cache placement, TTL, persist choice and reason; what runs on deactivate and on activate for the graph and map; and the simulation-restart condition. Plan 02-08 reads the persist table and the graphics design when checking memory on device.