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 | 04 | execute | 3 |
|
|
false |
|
|
Purpose: PERF-02. The tracer (02-02) proved the architecture on one tab and deliberately
left KEEP_ALIVE_PATHS seeded with only that tab. Widening it is not a one-line config
change: once a view's instance survives, onMounted fires exactly once for the session
and onBeforeUnmount never fires on tab-away. Every polling interval, websocket
subscription and window listener a main tab starts would otherwise run forever for every
tab ever visited — a CPU and memory drain on the low-power fleet hardware D-03 is
explicitly protecting — and every per-visit refresh would silently stop happening. That
is why registration and the lifecycle audit ship together, in one plan, rather than
registration landing early and correctness catching up later.
Output: the full audited main-tab registration set, and every main-tab view's side effects deliberately placed for an activate/deactivate lifecycle.
<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/codebase/CONVENTIONS.md @CLAUDE.md Task 1: Timers, subscriptions and listeners follow activation, not mount neode-ui/src/views/Home.vue, neode-ui/src/views/web5/Web5.vue, neode-ui/src/views/Chat.vue, neode-ui/src/views/Cloud.vue, neode-ui/src/views/Server.vue, neode-ui/src/views/Mesh.vue, neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts - `.planning/phases/02-ui-performance/02-02-SUMMARY.md` — the tracer tab's recorded side-effect placement decisions; this task repeats that audit across the remaining tabs and must stay consistent with the precedent set there. - `neode-ui/src/views/Home.vue` lines 293 and 524-560 — `onMounted` starts `systemStatsInterval` (10s `loadSystemStats`), `walletRefreshInterval` (30s `loadWeb5Status`), a `wsClient.subscribe` returning `unsubscribeWs`, a `wsWalletDebounce` timeout, and calls `hydrateWalletSnapshot()`. Read the matching `onBeforeUnmount` teardown too. - `neode-ui/src/views/web5/Web5.vue` lines 93-96, 140, 293, 337 and 371 — two `useCachedResource` resources already exist here plus an `onMounted` and an `onUnmounted`; read all of them. - `neode-ui/src/views/Chat.vue` lines 61-125 — `onMounted` adds a `window` `message` listener and starts a `ContextBroker`; `onBeforeUnmount` removes and stops them. - `neode-ui/src/views/Cloud.vue` — grep for `onMounted`, `onBeforeUnmount`, `onUnmounted`, `setInterval` and `subscribe` first, then read only those regions. The file is 1029 lines; do not read it whole. - `neode-ui/src/views/Server.vue` — grep for the same five tokens; its `onMounted` at line ~831 fires seven independent loads. Read only that region and any teardown. - `neode-ui/src/views/Mesh.vue` — grep for the same five tokens. The file is 2651 lines; read only the lifecycle regions. Its `onMounted` already does `await Promise.all([...])` across six fetch groups and must stay parallel. - `.planning/phases/02-ui-performance/02-RESEARCH.md` pitfall 4 and pitfall 6 — the failure modes this task exists to prevent. - Deactivating a view that owns a polling interval clears that interval; the poll callback is not invoked again while deactivated - Reactivating that view restarts the interval and immediately invokes its loader once, so the first frame after re-entry is not interval-stale - Deactivating a view that holds a websocket subscription unsubscribes it; reactivating re-subscribes exactly once, never twice - Deactivating a view that added a `window` event listener removes it; reactivating adds it back exactly once - Unmounting a view (rather than deactivating it) still tears everything down, so a non-cached mount path is unregressed - Two consecutive activations without an intervening deactivation do not double-arm any timer, subscription or listener For each view listed in `files`, classify every side effect its lifecycle hooks start into exactly one of three buckets and place it accordingly:- **Once per session** — stays in `onMounted`, unchanged. Example shape: a one-time
hydration from a stored snapshot.
- **Every entry** — moves to `onActivated`, and the `onMounted` call is removed so it
is not run twice on the first visit.
- **Only while visible** — started in `onActivated` and stopped in `onDeactivated`,
with the existing `onBeforeUnmount` / `onUnmounted` teardown left in place so the
non-cached path still cleans up.
Make every start idempotent: before arming a timer, clear any existing handle; before
subscribing, drop any existing unsubscribe function; before adding a listener, remove
it. Vue fires `onActivated` on first mount as well as on every reactivation, so a
non-idempotent start would double-arm on the first visit.
Concrete placements this task must make:
`Home.vue` — `hydrateWalletSnapshot()` is once-per-session and stays in `onMounted`.
`systemStatsInterval` and `walletRefreshInterval` are only-while-visible: clear both
in `onDeactivated`, re-arm both in `onActivated`. The `wsClient.subscribe` handle
(`unsubscribeWs`) and the `wsWalletDebounce` timeout are only-while-visible too. On
re-entry, `onActivated` must call `loadSystemStats()` and `loadWeb5Status()` once
immediately rather than waiting out the 10s and 30s intervals — a wallet balance is a
liveness-critical figure and must never render from a paused poll without an
immediate revalidation behind it.
`Chat.vue` — the `window` `message` listener and the `ContextBroker` are
only-while-visible. Move both to `onActivated` / `onDeactivated`, keeping the existing
`onBeforeUnmount` teardown. Note that `aiuiConnected` is set by a `ready` message from
the iframe: once the iframe survives deactivation, that message will not be re-sent on
re-entry, so `aiuiConnected` must not be reset on deactivate.
`Web5.vue`, `Cloud.vue`, `Server.vue`, `Mesh.vue` — apply the same three-bucket
classification to whatever their greps turn up. Do not restructure their fetch
orchestration in this task: `Mesh.vue`'s `Promise.all` fan-out and `Server.vue`'s
seven fire-and-forget loads are already concurrent, and converting them to cached
resources is plans 02-05 and 02-06. This task only relocates lifecycle side effects.
Write `neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts` covering the
six behaviors above against a small consumer component built with the same
activate/deactivate idiom, plus at least one assertion against a real converted view —
mount it inside a `<KeepAlive>`, deactivate, advance fake timers past its poll
interval, and assert its loader was not called while off screen and was called once on
reactivation.
Record in the SUMMARY, per view, every side effect and the bucket it was placed in.
Plan 02-08's on-device pass reads this table when checking for CPU drain.
`Apps.vue`: `appsAnimationDone` is a one-shot intro flag and stays in `onMounted`.
The 15s `connectionTimer` is an entry-scoped guard — under a surviving instance it
would be armed once on the first visit and never again, so its diagnosis of "unable to
connect" would go stale. Move arming to `onActivated` (clearing any prior handle
first) and clearing to `onDeactivated`, keeping the existing `onBeforeUnmount` clear.
`Discover.vue`: `discoverAnimationDone` is a one-shot flag and stays put. Its
`loadCommunityMarketplace()` and `loadBitcoinPruneStatus()` calls now resolve against
the shared cache keys the tracer introduced; confirm by reading `Marketplace.vue` as
the tracer left it, and route `Discover.vue` through the same cached resources rather
than duplicating the fetch. Wire the shared resource's `loadState` to
`RefreshIndicator` in this view's header the same way the tracer did — the subtle
in-header signal D-05 specifies, never a stale-age badge.
`Fleet.vue`: confirm it has no lifecycle side effects before changing anything. If the
grep finds none, change nothing and record that.
Then widen the registration set. Build `KEEP_ALIVE_PATHS` in
`neode-ui/src/views/dashboard/keepAliveRoutes.ts` from the imported `TAB_ORDER` plus
`/dashboard/discover`, minus any path that `02-FINDINGS.md` classifies `already fast`
with a `Remounted` value of false. A tab with no measured remount cost gains nothing
from an instance cache and D-02 says to leave already-fast views alone; a tab with
`Remounted: true` has a real cost to remove and is registered. Keep the source list
derived from `TAB_ORDER` rather than restating ten literal paths, so a future tab
addition does not silently miss registration. Record in the SUMMARY exactly which
paths ended up in the set and which were excluded with their measured reason.
Do not widen the match from exact-path to prefix-path. Every secondary screen in the
route table sits under a main tab's path prefix — `/dashboard/apps/:id`,
`/dashboard/marketplace/:id`, `/dashboard/cloud/:folderId`, `/dashboard/server/openwrt`,
`/dashboard/web5/credentials`, `/dashboard/settings/update` — and a prefix match would
instance-cache all of them, which D-04 rules out.
`KEEP_ALIVE_MAX` stays at 6 against roughly eleven registered paths, so the long tail
evicts. Plan 02-08 tunes it against on-device memory; do not change it here.
Extend `keepAliveLifecycle.test.ts` with the four behaviors above. The eviction test
is the important one: navigate through `KEEP_ALIVE_MAX + 2` registered paths with
mount/unmount-counting stubs and assert the least recently used stub was unmounted.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| off-screen view instance → node resources | A deactivated but resident view can hold timers, sockets and heavy graphics contexts against a low-power fleet node |
| cached render → user's belief about liveness | A surviving instance shows figures that were true when the tab was last visible, not necessarily now |
| main-tab path prefix → secondary screen | A loose path match would sweep secondary screens into the instance cache |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-02-03 | Denial of Service | Resident view instances and their timers on low-power fleet hardware | medium | mitigate | KEEP_ALIVE_MAX of 6 with LRU eviction (D-03); Task 1 stops every interval, subscription and listener on onDeactivated; Task 3 step 9 and plan 02-08 check idle CPU and memory on archi-dev-box |
| T-02-13 | Spoofing | A stale wallet balance or peer-reachability figure rendered as if current | high | mitigate | Task 1 requires onActivated to fire an immediate loader call for every live-data surface, so a resumed tab revalidates on the frame it returns rather than waiting out a paused poll; the RefreshIndicator from 02-02 makes the in-flight refresh visible |
| T-02-14 | Information Disclosure | A secondary screen accidentally instance-cached by a widened path match | medium | mitigate | Task 2 keeps exact-path matching and asserts shouldKeepAlive is false for six representative secondary-screen paths whose prefixes match a registered tab |
| T-02-15 | Denial of Service | A double-armed timer or duplicate subscription after repeated activations | low | mitigate | Task 1 requires every start to be idempotent and asserts that two consecutive activations do not double-arm |
| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope; onActivated and onDeactivated are Vue core. 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 or changed by this plan — new API, not drift:
neode-ui/src/views/dashboard/keepAliveRoutes.ts—KEEP_ALIVE_PATHSwidened from the tracer seed to the audited main-tab set, now derived fromTAB_ORDERneode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.tsonActivated/onDeactivatedhandlers added toHome.vue,Chat.vue,Apps.vue, and toWeb5.vue,Cloud.vue,Server.vue,Mesh.vuewhere their greps turn up only-while-visible side effects
Created elsewhere in Phase 02: shouldKeepAlive(), KEEP_ALIVE_MAX,
DashboardRouterView.vue, RefreshIndicator.vue, resources.clearAll(),
useCachedResource.test.ts, keepAliveTabs.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},
cache keys app-catalog, bitcoin.prune-status, app-details:<dataset>:<id>.
</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 off-screen-CPU truth carried as averification: backstopmarker because a unit test can prove a timer handle was cleared but not that the process draws no CPU — that half is checked on hardware in plan 02-08. - Open (RESEARCH pitfall 4 breadth): RESEARCH.md names
Apps.vue's connection timer andServer.vue's seven-call initializer as the known instances. This planner additionally foundHome.vue's twosetIntervalhandles plus awsClient.subscribe, andChat.vue'swindowmessage listener plusContextBroker.Cloud.vue,Web5.vueandMesh.vuewere not exhaustively read — Task 1 greps each foronMounted,onBeforeUnmount,onUnmounted,setIntervalandsubscribeand handles whatever it finds. If a view turns out to hold a side effect none of those five tokens catch, record it in the SUMMARY rather than letting it pass. - Open (D-01 versus D-02 boundary): D-01 says main tabs use both
<KeepAlive>anduseCachedResource; D-02 says already-fast views are left alone. This plan resolves the tension by measurement: a main tab is registered when02-FINDINGS.mdrecordsRemounted: truefor it, and excluded when it is classified already fast with no remount cost. Every exclusion is recorded with its measured reason. - FA-D (RESEARCH assumption A2):
KEEP_ALIVE_MAXstays at 6 here and is tuned against real on-device memory in plan 02-08, not guessed at again in this plan. </assumptions_and_flagged_items>
<success_criteria>
- Every main tab that measurably remounted now renders instantly from a surviving instance, with scroll and in-page state intact
- No off-screen tab runs a timer, a subscription or a listener
- Re-entering a live-data tab revalidates immediately rather than waiting out its poll
- One-shot flags fire once; entry-scoped guards re-arm per entry
- The instance cache is capped and evicts, proven by test and observed on device
- No secondary screen was instance-cached, and every excluded main tab has a recorded measured reason </success_criteria>