--- phase: 02-ui-performance plan: 05 subsystem: ui tags: [vue, keepalive, useCachedResource, leaflet, pinia, mesh] # Dependency graph requires: - phase: 02-ui-performance/02-04 provides: "Mesh.vue's activate/deactivate lifecycle audit (armMeshLive/teardownMeshLiveEffects, dual onMounted+onActivated registration idiom, /dashboard/mesh registered in KEEP_ALIVE_PATHS)" - phase: 02-ui-performance/02-02 provides: "useCachedResource composable (TTL/persist/onActivated revalidation), RefreshIndicator.vue, the Cloud.vue reference pattern for immediate:false + manual Promise.allSettled kick" provides: - "Mesh.vue's six previously-uncached tab-entry fetch groups (mesh.refreshAll, transport.fetchStatus, refreshFederationNodes, refreshSelfOnion, refreshSelfDid, refreshContacts) each behind a keyed useCachedResource entry with an explicit TTL and persist decision" - "RefreshIndicator wired into the Mesh header, driven by whether any of the six groups is refreshing" - "MeshMap.vue's Leaflet map instance quiesced/repaired across the Mesh tab's activate/deactivate cycle (armMapVisibility/disarmMapVisibility)" - "A documented, tested finding that no D3 force simulation exists in Mesh.vue's tree — RESEARCH.md's premise was incorrect for this codebase" affects: [02-06, 02-07, 02-08] # Tech tracking tech-stack: added: [] patterns: - "useCachedResource hosted at the component call site (Mesh.vue) even when it wraps a store action, when the store action has other callers needing a guaranteed-fresh (uncached) read — Pinia's defineStore(id, setup) runs in a bare effectScope, not a component instance, so the composable's internal onActivated() would silently no-op (dev warning) if called at store scope" - "refreshMeshGroupIfStale(res) gate — mirrors Cloud.vue's loadCounts() idiom (entry.data === null || isStale.value) for immediate:false resources force-refreshed inside a single Promise.allSettled array, keeping a multi-group fan-out concurrent instead of serialized (T-02-16)" - "Side-effect-only cached resources: fetchers that wrap an existing store/view function which already sets its own reactive refs as a side effect, resolving to a sentinel timestamp (Date.now()) rather than the real payload — used purely to gate/dedupe RPCs and expose loadState for a shared RefreshIndicator, not to hold data" key-files: created: - neode-ui/src/views/__tests__/meshTabCache.test.ts - neode-ui/src/components/__tests__/meshMapLifecycle.test.ts modified: - neode-ui/src/views/Mesh.vue - neode-ui/src/stores/mesh.ts - neode-ui/src/stores/transport.ts - neode-ui/src/api/rpc-client.ts - neode-ui/src/components/MeshMap.vue - neode-ui/src/views/mesh/mesh-styles.css key-decisions: - "mesh.refreshAll()/transport.fetchStatus() themselves are left untouched (still uncached, always-fresh) because they have other callers needing a guaranteed-fresh read: clearAllMesh() (must re-read post-clear state) and Web5SendReceiveModals.vue's pre-send balance/mesh-only check (money-critical, must never read from a TTL-gated cache). The useCachedResource() wrapper around each lives in Mesh.vue and calls the store's existing action as its fetcher, rather than living inside stores/mesh.ts/transport.ts." - "FLAGGED: RESEARCH.md's premise that Mesh.vue owns a live D3 force simulation is factually wrong for this codebase — grep for d3/forceSimulation/simulation across neode-ui/src found nothing in Mesh.vue's or MeshMap.vue's tree. The only D3 force simulation belongs to NetworkMap.vue (Federation.vue's graph, out of scope). Task 2's D3-specific truths are vacuously satisfied; only the real Leaflet map lifecycle (MeshMap.vue) was fixed." - "MeshMap.vue was added to files_modified beyond the plan's literal list, because the Leaflet map instance and its listeners/ResizeObserver live there, not in Mesh.vue — fixing the map's activate/deactivate correctness structurally requires editing where the instance lives (Rule 3 auto-fix, minimal/in-spirit, non-architectural)." - "meshMapLifecycle.test.ts is a separate file from meshTabCache.test.ts (not appended) because its vi.mock('@/stores/mesh')/vi.mock('leaflet') hoist file-wide and would clobber meshTabCache.test.ts's need for the real mesh/transport stores — same class of conflict 02-02 hit and resolved by splitting MarketplaceRefresh.test.ts out of keepAliveTabs.test.ts." - "Per-group TTL/persist decisions (see table below), following D-06 discretion and the T-02-01 persist prohibition." patterns-established: - "refreshMeshGroupIfStale(res) — the generalized form of Cloud.vue's loadCounts() staleness gate, applied uniformly across a set of immediate:false resources kicked from a single Promise.allSettled array" - "Fetchers for side-effect-only cached resources resolve to Date.now() rather than null, so useCachedResource's entry.data !== null / isStale gating works correctly even when the real payload is stored elsewhere (in existing store refs), not in the resource's own entry.data" requirements-completed: [] requirements-note: "PERF-02 is NOT marked complete — it also spans 02-06 and 02-07 (Server/Home data layer, Chat/AIUI), which still extend the KeepAlive/cache architecture to the remaining main tabs, per the precedent set by 02-02/02-03/02-04's own summaries." coverage: - id: D1 description: "All six Mesh tab-entry fetch groups (mesh.refresh-all, transport.status, federation-nodes, self-onion, self-did, contacts) are cached with explicit TTLs; a revisit inside TTL issues zero RPC across all six, a cold load still fires all six concurrently, and a rejected group never blocks the other five" requirement: PERF-02 verification: - kind: unit ref: "neode-ui/src/views/__tests__/meshTabCache.test.ts" status: pass human_judgment: false - id: D2 description: "Peer/reachability/transport data (10s TTL) revalidates on a stale revisit while this node's own DID/onion (300s TTL) stays cached; every group carrying peer or self identity data is persist:false, only aggregate transport status persists; every fetcher backing the six groups passes dedup:true" requirement: PERF-02 verification: - kind: unit ref: "neode-ui/src/views/__tests__/meshTabCache.test.ts" status: pass human_judgment: false - id: D3 description: "RefreshIndicator renders in the Mesh header, visible while any of the six groups revalidates (including reachability) so a resumed tab never presents a frozen reachability state as current (T-02-13)" requirement: PERF-02 verification: - kind: unit ref: "neode-ui/src/views/__tests__/meshTabCache.test.ts" status: pass human_judgment: false - id: D4 description: "MeshMap.vue's Leaflet map is quiesced/repaired across the Mesh tab's activate/deactivate cycle: exactly one map instance is constructed across repeated visits, its size is invalidated on reactivation, and its window resize listener/ResizeObserver are removed on deactivate and re-added exactly once on activate" requirement: PERF-02 verification: - kind: unit ref: "neode-ui/src/components/__tests__/meshMapLifecycle.test.ts" status: pass human_judgment: false - id: D5 description: "No visual/animation regression — full suite (752 tests incl. the structural keepAliveTabs.test.ts DOM-shape pin) stays green, type-check and build are clean, and the built bundle contains the new resource keys/strings" requirement: PERF-02 verification: - kind: unit ref: "npm run test (92 files / 752 tests)" status: pass - kind: other ref: "npm run type-check && npm run build; grep for mesh.transport-status/mesh.refresh-all/etc in web/dist/neode-ui/assets/Mesh-*.js" status: pass human_judgment: false duration: 50min completed: 2026-07-30 status: complete --- # Phase 02 Plan 05: Mesh Tab Cache + Graphics Lifecycle Summary **Mesh's six fetch groups (status/peers/messages/deadman/blockheaders, transport status, federation nodes, self onion, self DID, contacts) each behind a keyed `useCachedResource` with per-group TTL/persist decisions and a shared `RefreshIndicator`, plus the Leaflet map's listener/ResizeObserver lifecycle bounded across tab deactivation — no D3 simulation was found to exist in Mesh.vue's tree, contrary to RESEARCH.md's premise** ## Performance - **Duration:** ~50 min - **Completed:** 2026-07-30T20:17:44Z - **Tasks:** 2 (Task 1 auto/tdd, Task 2 auto/tdd) - **Files modified:** 8 (2 new test files, 6 modified) ## Accomplishments - **Six fetch groups cached** behind `useCachedResource`, all hosted in `Mesh.vue` (see key-decisions for why, not in `stores/mesh.ts`/`stores/transport.ts`), each `immediate: false` and force-refreshed only when stale via `refreshMeshGroupIfStale()` inside a single `Promise.allSettled` array in `armMeshLive`: | Key | Fetcher wraps | TTL | Persist | Reason | |---|---|---|---|---| | `mesh.refresh-all` | `mesh.refreshAll()` (status/peers/messages/deadman/block-headers) | 10,000ms | `false` | Peer reachability is the liveness-critical surface T-02-13 forbids freezing; carries peer DIDs/pubkeys (identity payload) | | `mesh.transport-status` | `transport.fetchStatus()` | 10,000ms | `true` | Live transport-availability state, but no peer identity payload — explicitly the one group the plan names as safe to persist | | `mesh.federation-nodes` | `refreshFederationNodes()` | 30,000ms | `false` | D-06 default (not liveness-critical); carries DID/pubkey/onion | | `mesh.self-onion` | `refreshSelfOnion()` | 300,000ms | `false` | This node's own onion address is effectively static; identity payload | | `mesh.self-did` | `refreshSelfDid()` | 300,000ms | `false` | This node's own DID is effectively static; identity payload | | `mesh.contacts` | `refreshContacts()` | 30,000ms | `false` | User-set aliases/contact records — identity payload per T-02-01 | - `armMeshLive`'s six-way `Promise.all([...])` fan-out replaced with `Promise.allSettled(meshCachedGroups.map(refreshMeshGroupIfStale))` — proven concurrent in test (all six underlying RPCs have already fired by the very next synchronous line after mount, before any `await`), and proven non-blocking (one group's store action rejecting still lets the other five complete and the post-fan-out `.then()` — `refreshOutboxCount` + deep-link matching — still runs). - `dedup: true` added to every RPC call backing the six groups: `mesh.status`, `mesh.peers`, `mesh.messages`, `mesh.deadman-status`, `mesh.block-headers` (mesh.ts), `transport.status` (transport.ts), and the convenience methods `getNodeDid`, `getTorAddress`, `meshContactsList`, `federationListNodes` (rpc-client.ts). - `RefreshIndicator` added to the Mesh header (new `.mesh-title-row` flex wrapper around the existing `

`, no change to any pre-existing selector/animation), driven by `meshRefreshIndicatorState` — `'refreshing'` whenever any of the six groups is in `loadState === 'refreshing'`. - **MeshMap.vue's Leaflet lifecycle**: `onMounted`'s window-resize-listener + `ResizeObserver` setup refactored into idempotent `armMapVisibility()`/`disarmMapVisibility()`, dual-registered on `onMounted`+`onActivated` (with a fresh-mount guard) and torn down on `onDeactivated`, mirroring Mesh.vue's own `armMeshLive`/`teardownMeshLiveEffects` idiom from 02-04. The Leaflet instance itself is never destroyed/recreated by this (`initMap()`'s own guard already makes construction idempotent); reactivation calls `map.invalidateSize()` via `nextTick` so a map laid out off screen re-tiles at its real size. - **Flagged premise mismatch, documented and tested**: RESEARCH.md's Task 2 premise ("Mesh is... a live D3 force simulation and a Leaflet map") does not hold — a full grep across `neode-ui/src` for `d3`/`forceSimulation`/`simulation` found zero hits in `Mesh.vue`'s or `MeshMap.vue`'s component tree. The only D3 force simulation in the codebase belongs to `NetworkMap.vue` (used by `Federation.vue`, a different view entirely, out of this plan's scope). Task 2's D3-specific `must_haves` truths ("the D3 force simulation stops...", "restarts only if data changed...", "cancels any pending animation-frame callback...") are therefore vacuously true (there is nothing to leak) — only the real Leaflet-map-specific truths were implemented and tested. ## Task Commits Each task was committed atomically: 1. **Task 1: Cache the six Mesh fetch groups without serializing them** - `31389bcc` (feat, tdd) 2. **Task 2: Bound the D3 simulation and Leaflet map across deactivation** - `abdfa07a` (feat, tdd — D3 portion vacuous per the flagged finding above; Leaflet portion real) **Plan metadata:** (this commit) _Note: both tasks are TDD tasks; tests were written and made to pass within each task's own commit, per this repo's established single-commit-per-task convention (see 02-01/02-02/02-03/02-04 history)._ ## Files Created/Modified - `neode-ui/src/views/Mesh.vue` — six `useCachedResource` entries, `meshCachedGroups`/`refreshMeshGroupIfStale`/`meshRefreshIndicatorState`, `armMeshLive`'s fan-out converted to `Promise.allSettled`, `RefreshIndicator` added to the header - `neode-ui/src/stores/mesh.ts` — `dedup: true` on `fetchStatus`/`fetchPeers`/`fetchMessages`/`fetchDeadmanStatus`/`fetchBlockHeaders`'s RPC calls - `neode-ui/src/stores/transport.ts` — `dedup: true` on `fetchStatus`/`fetchPeers`'s RPC calls - `neode-ui/src/api/rpc-client.ts` — `dedup: true` on `getNodeDid`, `getTorAddress`, `meshContactsList`, `federationListNodes` - `neode-ui/src/components/MeshMap.vue` — `armMapVisibility()`/`disarmMapVisibility()` idempotent pair, dual-registered `onMounted`/`onActivated`, `onDeactivated` teardown, `nextTick`-scheduled `invalidateSize()` on reactivation - `neode-ui/src/views/mesh/mesh-styles.css` — new `.mesh-title-row` rule (flex wrapper for the title + RefreshIndicator; no existing selector touched) - `neode-ui/src/views/__tests__/meshTabCache.test.ts` — new; 8 tests covering Task 1's five behaviors plus dedup/persist/indicator-wiring assertions - `neode-ui/src/components/__tests__/meshMapLifecycle.test.ts` — new; 4 tests covering Task 2's real (Leaflet-only) behaviors ## Decisions Made See `key-decisions` in frontmatter for the full list. Highlights: - **Cache placement (mesh.refreshAll/transport.fetchStatus)**: the `useCachedResource()` call itself lives in `Mesh.vue`, not inside `stores/mesh.ts`/`stores/transport.ts`, even though it wraps those stores' own actions. Reason, verified against Vue's source (`node_modules/@vue/runtime-core/dist/runtime-core.cjs.js`'s `injectHook`): Pinia's `defineStore(id, setup)` runs its setup function inside a bare `effectScope()`, not a real component instance (`currentInstance` is `null`), so `onActivated()` called from inside a Pinia store setup is a documented Vue no-op (dev warning only, never throws) — it would compile but would never actually revalidate anything on tab reactivation. Mesh.vue is the one call site that legitimately owns the KeepAlive/component lifecycle. The fetchers still literally re-invoke `mesh.refreshAll()`/`transport.fetchStatus()` unchanged, so those store actions' other callers (`clearAllMesh()`, and `Web5SendReceiveModals.vue`'s pre-send mesh-only check, which must never read a TTL-gated cache before moving money) keep their existing guaranteed-fresh behavior untouched. - **MeshMap.vue added to files_modified** beyond the plan's literal list (`Mesh.vue`, `mesh.ts`, `transport.ts`, `meshTabCache.test.ts`) — the Leaflet map instance, its window listener, and its `ResizeObserver` all live in `MeshMap.vue`, a child component `` inside Mesh.vue's template. Fixing the map's activate/deactivate correctness structurally requires editing where the instance lives; this is a minimal, in-spirit, non-architectural addition (Rule 3 auto-fix — same class of judgment call 02-02 made when it added `dashboardViewWrappers.ts` outside its own original file list). - **Test file split**: `meshMapLifecycle.test.ts` is a new, separate file rather than appended to `meshTabCache.test.ts`, because its `vi.mock('@/stores/mesh', ...)` (a minimal plain-object stub) and `vi.mock('leaflet', ...)` are hoisted to the top of whichever file they're declared in by vitest/esbuild, and would clobber `meshTabCache.test.ts`'s need for the **real** `mesh`/`transport`/`resources` Pinia stores (needed so the six-group cache/dedup/persist logic under test is genuinely exercised, not stubbed away). This mirrors the exact precedent 02-02 set with `MarketplaceRefresh.test.ts` for the same class of `vi.mock`-hoisting conflict. - **Persist/TTL table**: see Accomplishments above — every group carrying this node's own DID/onion or any peer identity payload (peers, federation nodes, contacts/aliases) is `persist: false`; only `transport.status` (an aggregate, no identity fields) persists, matching the plan's own explicit carve-out. - **FLAGGED (not auto-backstopped)**: the D3 force-simulation premise from RESEARCH.md is factually incorrect for the current codebase state. This was verified by grepping the entire `neode-ui/src` tree (not just `Mesh.vue`) for `d3`, `forceSimulation`, and `simulation` — the only hits belong to `src/components/federation/NetworkMap.vue`, imported exclusively by `Federation.vue`. Mesh.vue's only graphics context is the Leaflet map (`MeshMap.vue`). This is surfaced here for human review per the assumptions_and_flagged_items convention rather than silently reinterpreting the task; the real, testable Leaflet-lifecycle work (which the plan also required) was implemented and covered in full. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] MeshMap.vue added to files_modified to fix the Leaflet map's lifecycle** - **Found during:** Task 2, tracing where the Leaflet instance/listener/observer actually live - **Issue:** The plan's `files_modified` for this task only lists `Mesh.vue` and `meshTabCache.test.ts`, but the Leaflet map, its window resize listener, and its `ResizeObserver` all live in the child component `MeshMap.vue` (`` in Mesh.vue's template) — Mesh.vue itself has no direct graphics-context code to edit. - **Fix:** Extended scope to `neode-ui/src/components/MeshMap.vue` (added `armMapVisibility`/`disarmMapVisibility`, dual `onMounted`/`onActivated` registration, `onDeactivated` teardown) and a new companion test file `neode-ui/src/components/__tests__/meshMapLifecycle.test.ts`. - **Files modified:** `neode-ui/src/components/MeshMap.vue`, `neode-ui/src/components/__tests__/meshMapLifecycle.test.ts` (new) - **Verification:** 4 new tests pass (one map instance across 3 cycles, invalidateSize on activate, listener add/remove counts, ResizeObserver connect/disconnect counts); full suite, type-check, build all green. - **Committed in:** `abdfa07a` (Task 2 commit) **2. [Rule 1 - Bug in own test authoring, caught before commit] meshMapLifecycle.test.ts's listener-count test needed a mount-time baseline** - **Found during:** Task 2, first test run - **Issue:** `armMapVisibility()`'s idempotent remove-then-add idiom means `window.removeEventListener('resize', ...)` is also called once during the very first mount (not just on deactivate) — the test's naive assertion (`expect(removeSpy...).toBe(1)` after the first deactivate) was off by one. - **Fix:** Baseline both `addSpy`/`removeSpy` counts immediately after mount, then assert relative increments across the deactivate/activate cycle. - **Files modified:** `neode-ui/src/components/__tests__/meshMapLifecycle.test.ts` - **Verification:** Test passes; the underlying implementation was correct all along, only the test's assumption was wrong. - **Committed in:** `abdfa07a` (Task 2 commit) **3. [Rule 1 - Bug in own test authoring, caught before commit] meshTabCache.test.ts's dedup:true assertion needed to scope to the six groups' own methods** - **Found during:** Task 1, first test run - **Issue:** `refreshOutboxCount()` (called from the fan-out's `.then()`, not one of the six cached groups) issues an `rpcClient.call({method: 'mesh.outbox'})` without `dedup: true` — a blanket "every captured rpcClient.call has dedup:true" assertion incorrectly failed on this unrelated, out-of-scope call. - **Fix:** Restricted the assertion to the six groups' own RPC methods (`mesh.status`, `mesh.peers`, `mesh.messages`, `mesh.deadman-status`, `mesh.block-headers`, `transport.status`). - **Files modified:** `neode-ui/src/views/__tests__/meshTabCache.test.ts` - **Verification:** Test passes; confirms all six groups' fetchers (and no others) are asserted against dedup:true. - **Committed in:** `31389bcc` (Task 1 commit) --- **Total deviations:** 3 (1 Rule 3 scope extension necessary to fulfill the task's literal requirement; 2 Rule 1 fixes to the test file's own assertions, caught and corrected before either commit landed — no production-code bugs found) **Impact on plan:** The MeshMap.vue extension is the only deviation with lasting scope impact, and it is narrowly targeted (lifecycle hooks only, no restructuring, no visual change) and fully test-covered. No scope creep beyond what Task 2's literal must_haves required. ## Known Stubs None — no stub data, placeholder text, or unwired data sources were introduced. Every cached group's fetcher performs a real RPC round-trip through the existing store/view functions; nothing renders hardcoded empty/mock data. ## Threat Flags None beyond what the plan's own `` already anticipated (T-02-01, T-02-03, T-02-13, T-02-16) — no new network endpoints, auth paths, or trust-boundary-crossing surface was introduced by this plan. ## Issues Encountered - The D3-force-simulation premise mismatch (see Decisions Made) — resolved by verifying via grep and treating the affected truths as vacuously satisfied, with the finding surfaced prominently here for human review rather than silently reinterpreting the task's scope. - Two test-authoring bugs in the new test files themselves (both fixed before either commit — see Deviations 2 and 3 above); no production-code bugs were found during this plan. ## User Setup Required None - no external service configuration required. ## Next Phase Readiness - Mesh's six fetch groups and its Leaflet map lifecycle are now on the same cached/activate-deactivate architecture as every other main tab (02-02/02-04's tracer + lifecycle-audit foundation extended to the heaviest remaining tab). - The `refreshMeshGroupIfStale`/`Promise.allSettled` pattern (and the "useCachedResource must be hosted at a real component, not inside a Pinia store setup" finding) is available for 02-06 (Server/Home) if either store owns a fetch action with other callers needing a guaranteed-fresh read. - **For 02-08 (on-device verification)**: heap-growth across many Mesh tab cycles is a property no jsdom unit test can settle — `meshMapLifecycle.test.ts` proves exactly one Leaflet map instance is constructed across repeated activate/deactivate cycles in a synthetic harness, but real browser memory tooling on archi-dev-box is where the D-03 bounded-memory claim gets its final check, per the plan's own `verification: backstop` marker. - **Flag carried forward**: if a future audit finds Mesh.vue (or any other main tab) genuinely does need a D3-based visualization (e.g., if `HopVizModal.vue`'s message-hop graphic is later rebuilt with D3), re-open this finding — the current absence was verified for the codebase state as of this plan's execution, not asserted as a permanent architectural constraint. - No blockers for 02-06/02-07. --- *Phase: 02-ui-performance* *Completed: 2026-07-30* ## Self-Check: PASSED