Files
archy/.planning/phases/02-ui-performance/02-04-PLAN.md
T

370 lines
28 KiB
Markdown

---
phase: 02-ui-performance
plan: 04
type: execute
wave: 3
depends_on: ["02-02"]
files_modified:
- neode-ui/src/views/dashboard/keepAliveRoutes.ts
- 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/Apps.vue
- neode-ui/src/views/Discover.vue
- neode-ui/src/views/Fleet.vue
- neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts
autonomous: false
requirements: [PERF-02]
must_haves:
truths:
- "Every main tab the profiling pass showed remounting on revisit is instance-cached, and switching to it and back shows its previous content immediately"
- "A polling interval, websocket subscription or window listener started by a main tab stops while that tab is off screen and restarts when the tab is re-entered"
- "Returning to a tab that polls live data re-reads that data immediately on re-entry rather than waiting out the poll interval"
- "A one-shot intro or animation flag still fires exactly once per session and does not replay on every tab revisit"
- "A connection-timeout timer that only makes sense on a fresh entry is re-armed on re-entry, not left armed from the first visit"
- "Visiting all main tabs in sequence leaves at most KEEP_ALIVE_MAX view instances resident — the least recently used tab is evicted"
- "Main tabs classified already fast by the profiling pass, with no measured remount cost, are left unregistered and the reason is recorded (D-02)"
- "No secondary screen is instance-cached as a side effect of widening the registration set (D-04)"
- statement: "Off-screen tabs consume no measurable CPU from their own timers while deactivated"
verification: backstop
prohibitions:
- "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 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"
artifacts:
- path: "neode-ui/src/views/dashboard/keepAliveRoutes.ts"
provides: "KEEP_ALIVE_PATHS widened from the tracer's single path to the full audited main-tab set"
exports: ["shouldKeepAlive", "KEEP_ALIVE_PATHS", "KEEP_ALIVE_MAX"]
- path: "neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts"
provides: "Assertions that deactivation stops timers and subscriptions and reactivation restarts them and refreshes live data"
key_links:
- from: "neode-ui/src/views/dashboard/keepAliveRoutes.ts"
to: "neode-ui/src/views/dashboard/useRouteTransitions.ts"
via: "KEEP_ALIVE_PATHS is built from the exported TAB_ORDER main-tab list, not from isDetailRoute"
pattern: "TAB_ORDER"
- from: "neode-ui/src/views/Home.vue"
to: "vue onActivated / onDeactivated"
via: "poll intervals and the websocket subscription are torn down on deactivate and re-armed with an immediate refresh on activate"
pattern: "onDeactivated"
- from: "neode-ui/src/views/Chat.vue"
to: "vue onActivated / onDeactivated"
via: "the window message listener and ContextBroker follow activation rather than mount/unmount"
pattern: "onDeactivated"
---
<objective>
Turn instance caching on for every main tab the profiling pass showed remounting, and make
every main tab correct under that lifecycle first.
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Timers, subscriptions and listeners follow activation, not mount</name>
<files>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</files>
<read_first>
- `.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.
</read_first>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveLifecycle.test.ts && npm run test && npm run type-check</automated>
</verify>
<acceptance_criteria>
- `neode-ui/src/views/Home.vue` and `neode-ui/src/views/Chat.vue` each reference `onDeactivated`: `grep -c "onDeactivated" neode-ui/src/views/Home.vue` and the same for `Chat.vue` are each at least 1
- `neode-ui/src/views/Home.vue` calls its stats and wallet loaders from `onActivated` so re-entry does not wait out the poll interval
- `npm run test -- src/views/dashboard/__tests__/keepAliveLifecycle.test.ts` exits 0 with all six behaviors covered
- A test asserts a paused interval's callback is not invoked while the view is deactivated
- A test asserts two consecutive activations do not double-arm a timer, subscription or listener
- `npm run test` (full suite) exits 0 and `npm run type-check` exits 0
- `Mesh.vue`'s `onMounted` still awaits a `Promise.all` and `Server.vue` still issues its loads without awaiting them sequentially — neither fetch fan-out was serialized by this task
- The SUMMARY contains a per-view table of every side effect and its assigned bucket
</acceptance_criteria>
<done>Every main-tab side effect is deliberately placed for an activate/deactivate lifecycle, off-screen tabs run no timers or subscriptions, and re-entering a live-data tab refreshes it immediately.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: One-shot flags, entry timers, and widening the registration set</name>
<files>neode-ui/src/views/Apps.vue, neode-ui/src/views/Discover.vue, neode-ui/src/views/Fleet.vue, neode-ui/src/views/dashboard/keepAliveRoutes.ts, neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts</files>
<read_first>
- `neode-ui/src/views/Apps.vue` lines 383 and 544-560 — `onMounted` sets `appsAnimationDone = true` and, when the store is not connected, arms a 15s `connectionTimer` that raises `connectionError`; `onBeforeUnmount` clears the timer.
- `neode-ui/src/views/Discover.vue` lines 232 and 598-605 — `onMounted` sets `discoverAnimationDone` and calls `loadCommunityMarketplace()` (guarded on an empty list) and `loadBitcoinPruneStatus()`.
- `neode-ui/src/views/Fleet.vue` — 154 lines with no lifecycle hook found by grep; confirm before changing anything.
- `neode-ui/src/views/Marketplace.vue` as left by plan 02-02 — the tracer already moved its catalog and prune-status fetches onto the shared `app-catalog` and `bitcoin.prune-status` cache keys; `Discover.vue` calls the same loader and should pick up the same entries without a second conversion.
- `neode-ui/src/views/dashboard/keepAliveRoutes.ts` — as created by 02-02, seeded with the tracer path only.
- `neode-ui/src/views/dashboard/useRouteTransitions.ts``TAB_ORDER` (now exported by 02-02) is the canonical main-tab path list: `/dashboard`, `/dashboard/apps`, `/dashboard/marketplace`, `/dashboard/cloud`, `/dashboard/mesh`, `/dashboard/server`, `/dashboard/web5`, `/dashboard/fleet`, `/dashboard/chat`, `/dashboard/settings`.
- `neode-ui/src/router/index.ts` — confirm `/dashboard/discover` is a real route (`name: 'discover'`, `Discover.vue`) that `TAB_ORDER` does not list, and that `/dashboard/monitoring` is reached from Web5 rather than from the tab bar.
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — the per-surface `Remounted` column and primary cause, which decide which tabs get registered.
</read_first>
<behavior>
- A one-shot intro flag set on first entry is still set exactly once across three visits to the same tab
- A connection-timeout timer that guards a fresh entry is re-armed on each re-entry and cleared on each exit, so it never fires against a stale visit
- `shouldKeepAlive` returns true for every registered main-tab path and false for every detail path, including detail paths whose prefix matches a registered path
- Visiting more distinct registered tabs than `KEEP_ALIVE_MAX` leaves exactly `KEEP_ALIVE_MAX` instances resident, and the least recently used one has been unmounted
</behavior>
<action>
First, finish the lifecycle audit for the remaining main tabs.
`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.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveLifecycle.test.ts src/views/dashboard/__tests__/keepAliveTabs.test.ts && npm run test && npm run type-check && npm run build</automated>
</verify>
<acceptance_criteria>
- `neode-ui/src/views/dashboard/keepAliveRoutes.ts` imports `TAB_ORDER` from `useRouteTransitions` and builds `KEEP_ALIVE_PATHS` from it
- `shouldKeepAlive({ path: '/dashboard/apps' })` is true and `shouldKeepAlive({ path: '/dashboard/apps/bitcoin' })` is false — asserted in the test file
- The same false assertion holds for `/dashboard/marketplace/x`, `/dashboard/cloud/x`, `/dashboard/server/openwrt`, `/dashboard/web5/credentials` and `/dashboard/settings/update`
- The eviction test navigates through more than `KEEP_ALIVE_MAX` registered paths and asserts exactly `KEEP_ALIVE_MAX` instances remain resident
- `neode-ui/src/views/Apps.vue` arms its connection timer from `onActivated` and clears it from `onDeactivated`
- `neode-ui/src/views/Discover.vue` renders `RefreshIndicator` bound to the shared catalog resource's `loadState`
- `npm run test` exits 0, `npm run type-check` exits 0, `npm run build` exits 0
- The built bundle carries the widened set: `grep -rl "KEEP_ALIVE\|shouldKeepAlive" web/dist/neode-ui/assets | head -1` prints a file
- The SUMMARY lists every registered path and every excluded path with its measured reason
</acceptance_criteria>
<done>Every main tab that measurably remounts is registered, every one-shot and entry-scoped side effect is correctly placed, no secondary screen slipped into the instance cache, and eviction is proven by test.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Walk every main tab and confirm instant revisits with no off-screen drain</name>
<what-built>
Instance caching extended from the single tracer tab to every main tab that the
profiling pass showed remounting, with each tab's timers, websocket subscriptions and
window listeners moved onto the activate/deactivate lifecycle so an off-screen tab
costs nothing, and re-entering a live-data tab refreshes it immediately. The instance
cache is capped at 6 with least-recently-used eviction.
</what-built>
<how-to-verify>
1. From the repo root run `./scripts/dev-start.sh` and open the :8100 dev preview
pointed at archi-dev (password `password123`).
2. Visit every main tab once in order: Home, Apps, App store, Cloud, Mesh, Server,
Web5, Fleet, Chat, Settings. Let each finish loading.
3. Now switch between them at random. Expected on every revisit: content appears
immediately, scroll position and in-page state (search text, selected sub-tab,
expanded panels) are as you left them, and no intro animation replays.
4. Home specifically: note the wallet balance, leave Home for a minute, come back.
Expected: the previous figure is on screen instantly AND it updates within a second
or two as the immediate re-entry refresh lands — it must not sit frozen waiting for
the next 30s poll.
5. Chat specifically: open Chat, wait for the AIUI panel to load, switch away, switch
back. Expected: the panel is still loaded — it does not reload from scratch.
6. Apps specifically: with the backend running, open Apps, leave, and come back.
Expected: no spurious "Unable to connect to server" message appears.
7. Open a secondary screen from any tab (an app detail page, a cloud folder, the
OpenWrt page), navigate away and back. Expected: these still behave as before —
they are deliberately not instance-cached.
8. Cycle through all ten tabs twice, then return to the first one you visited.
Expected: it may show a brief load — it was evicted by the cap. This is correct.
9. Leave the browser sitting on one tab for a few minutes after having visited all of
them. The node should be idle; if the machine's fan spins up or the UI gets
sluggish, an off-screen timer is still running — report which tab you visited last.
</how-to-verify>
<resume-signal>Type "approved", or describe what you saw: which step, which tab, what happened instead.</resume-signal>
</task>
</tasks>
<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_PATHS` widened from the tracer seed to the audited main-tab set, now derived from `TAB_ORDER`
- `neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts`
- `onActivated` / `onDeactivated` handlers added to `Home.vue`, `Chat.vue`, `Apps.vue`, and to `Web5.vue`, `Cloud.vue`, `Server.vue`, `Mesh.vue` where 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's `must_haves.truths`, with the off-screen-CPU truth carried as a `verification: backstop` marker 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 and `Server.vue`'s seven-call initializer as the known instances. This planner additionally found `Home.vue`'s two `setInterval` handles plus a `wsClient.subscribe`, and `Chat.vue`'s `window` message listener plus `ContextBroker`. `Cloud.vue`, `Web5.vue` and `Mesh.vue` were not exhaustively read — Task 1 greps each for `onMounted`, `onBeforeUnmount`, `onUnmounted`, `setInterval` and `subscribe` and 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>` and `useCachedResource`; D-02 says already-fast views are left alone. This plan resolves the tension by measurement: a main tab is registered when `02-FINDINGS.md` records `Remounted: true` for 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_MAX` stays 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>
<verification>
- `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 and the widened registration appears in `web/dist/neode-ui/assets`
- The human-verify checkpoint is approved against archi-dev on the :8100 preview, including the Home wallet-freshness step and the eviction step
</verification>
<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>
<output>
Create `.planning/phases/02-ui-performance/02-04-SUMMARY.md` when done. It MUST record:
the per-view table of every side effect and the bucket it was placed in; the final
`KEEP_ALIVE_PATHS` contents; every main-tab path excluded from registration with its
measured reason; and any side effect found that the five-token grep would have missed.
Plans 02-05, 02-06, 02-07 and 02-08 read all four from this file.
</output>