Files
archy/.planning/phases/02-ui-performance/02-FINDINGS.md
T
archipelagoandClaude Opus 5 48a2ff7c16 docs(02-11): four-way re-measure, final per-surface verdicts, second Discover cause
Deployed the poller fix to archi-dev-box and re-ran the frozen harness
(02-PERF-FINAL.json, 5 runs/surface). Web5 fixed (275ms, below both
its 566ms pre-phase-2 baseline and the 300ms pass bar). Server's
regression closed (574ms, below 738ms baseline) though not yet under
300ms. Fleet substantially improved (790ms, down from a 2631ms
regression). AppDetails restored to at/near its own baseline.

Investigated the two open items the coordinator raised:
- OpenWrtGateway: this run's 5/5 samples failed with a Chromium
  "Target crashed" error cascading from an unrelated surface earlier
  in the same harness run — recorded as not-measurable, not written
  in as data. Separately confirmed via source (OpenWrtGateway.vue's
  h1 renders unconditionally, and a "No router configured" RPC error
  deterministically shows a real Connect-to-Router form) that the
  prior baseline/after/remeasure numbers were measuring a genuine,
  substantive disconnected-state UI render, not an empty/error page —
  so the "six confirmed regressions" count is not retracted, but the
  numbers are flagged as reflecting one specific code branch.
- Discover (1389ms, worst remaining, least improved): profiled
  directly and found a SECOND, distinct, phase-2-class cause —
  showStagger/card-stagger entrance-animation classes are baked into
  the DOM at first mount and never programmatically removed (the flag
  is a correctly-scoped once-per-session const, but nothing ever
  re-renders to strip the class), so every KeepAlive detach/reattach
  cycle restarts the CSS animation on reactivation, replaying the full
  entrance cascade on every revisit. Confirmed via a diagnostic
  showing DOM card count doubling transiently on every revisit and an
  extended animationstart/animationend event log. Not fixed this pass
  — the safe fix's blast radius spans 5+ files outside this plan's
  scope (Apps.vue, Marketplace.vue, Home.vue, several Web5 sub-cards)
  and needs its own real-device verification budget, matching the
  precedent 02-02's original KeepAlive rollout needed for this exact
  class of change. Named and evidenced, recommended as a dedicated
  follow-up rather than expanded into this plan under time pressure.

REQUIREMENTS.md's PERF-02/PERF-03 rows updated to the final state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:32:19 -04:00

103 KiB
Raw Blame History

Phase 02 UI Performance — Findings (D-10)

Status: Gate doc — committed before any neode-ui/src change lands in this phase (D-10). Every claim below cites a field in 02-PERF-BASELINE.json; nothing here is inferred from code reading alone.

Method

Target: archi-dev-box (the on-device verification target named by D-11), resolved to http://archi-dev-box from the executing workstation. This is a real Archipelago node, not the local mock backend or the :8100 dev preview — 02-PERF-BASELINE.json's run header records baseUrl: "http://archi-dev-box" and commit: "a75b6709" (the harness commit the run was taken against).

Harness: neode-ui/e2e/perf/{surfaces,measure,surface-perf.spec}.ts (built and committed in this plan's Task 1). Run command:

cd neode-ui
ARCHY_BASE_URL=http://archi-dev-box \
ARCHY_PASSWORD=<the node's real UI password, provided out-of-band — never committed> \
ARCHY_PERF_OUT=../.planning/phases/02-ui-performance/02-PERF-BASELINE.json \
npx playwright test e2e/perf/surface-perf.spec.ts --project=chromium --reporter=line

Sample count: 3 runs per surface (runs: 3 in the artifact header), each run measuring a fresh first-visit (navigate from dashboard Home to the surface) and a revisit (navigate to the neutral Settings tab — or close the surface's own trigger, for the wallet-send modal — then return via the same UI-click path) in the same authenticated session. 02-PERF-BASELINE.json's firstVisitMs/revisitMs/revisitRpcCount top-level fields are the median across successful runs; samples holds every individual run (including any per-run errors) so no data is discarded.

Field meanings:

  • firstVisitMs / revisitMs — wall-clock ms from the click that starts navigation to the surface's contentSelector becoming visible.
  • revisitRpcCount — number of POSTs to /rpc/v1 observed during the revisit window (method name + timing only, never bodies — see the harness's threat mitigation).
  • maxConcurrentRpc — peak number of RPC calls in flight simultaneously during the revisit, derived by sweep-line over each call's [start, start+duration] interval. A waterfall shows maxConcurrentRpc: 1 with 2+ calls; a parallel fan-out shows maxConcurrentRpc close to the total call count.
  • remounted — read back from a dataset-stamp on the view's root element after the away/back round-trip. true means the component instance was destroyed and recreated (expected everywhere right now — grep -r "KeepAlive" neode-ui/src returns 0 hits, confirmed again this session).

All 15 SURFACES rows were run against archi-dev-box. 13 of 15 produced clean first-visit/revisit/RPC/remount numbers. 2 are recorded as unmeasured with the specific reason (never as "already fast" — see the Mesh and Chat rows below).

A methodological caveat that materially affects three rows' RPC evidence: several navSteps chains reach their target surface by first clicking through another main tab (Marketplace and its own AppDetails go through Home's "Browse Store" link; OpenWrtGateway goes through Server's "OpenWrt Gateway" link). Home.vue and Server.vue both fire a burst of RPCs from their own onMounted the instant they're briefly mounted mid-chain (system.stats, fips.status, vpn.status/vpn.list-peers, wallet.*, network.list-interfaces, tor.list-services, etc.). Those in-flight requests are still resolving when the chain lands on the destination surface, and the harness's RPC tracker — which starts recording at the top of the revisit click-chain, not at the destination surface's own mount — captures them as if they belonged to the destination. This was confirmed by comparing captured method names directly: OpenWrtGateway's captured set (vpn.list-peers, network.list-interfaces, tor.list-services, fips.status) is an exact subset of Server.vue's own 8-call set measured cleanly on the server row (whose navSteps do not transit another tab). Where this confound applies, the affected row's Primary Cause is classified conservatively (remount storm, the reading the elevated revisitMs alone supports) rather than uncached fetch, and the confound is called out explicitly in that row's Intended Fix column so 02-02/02-03 re-verify with DevTools before assuming the RPC count is real. Rows whose navSteps reach their target directly (Apps, Cloud, Mesh, Server, Web5, Chat, AppDetails, CloudFolder) have no such confound — their RPC counts are trustworthy as measured.

Per-Surface Findings

Surface First visit (ms, median) Revisit (ms, median) Revisit RPC count Max concurrent RPC Remounted Primary cause Intended fix Owning plan
Home (wallet figures) 354 390 1 1 true already fast Left alone (D-02) — still gets wrapped in <KeepAlive> per D-01 for scroll/animation-state persistence, no data-cache conversion needed 02-04 (KeepAlive wrapping only)
Apps (My Apps) 359 267 1 1 true already fast Left alone (D-02) — KeepAlive wrapping only 02-04 (KeepAlive wrapping only)
Marketplace (App Store) 1228 2033 13 12 true remount storm KeepAlive + convert to useCachedResource; RPC count is confounded by the Home-transit artifact above — do not treat 13 calls as Marketplace's own fetch pattern without re-verifying via DevTools 02-02 (tracer/app store)
Discover (App Store tab) 1117 1083 1 1 true remount storm Convert to useCachedResource alongside Marketplace (same app-store surface family, D-09) 02-02 (tracer/app store)
Cloud / Files 463 247 0 0 true already fast Left alone (D-02) — already on useCachedResource per code read; still gets KeepAlive wrapping 02-04 (KeepAlive wrapping only)
Mesh unmeasured .mesh-status-grid never left the hidden/loading branch within the 20s wait window across all 3 runs — the device did not report connected on this node in the window measured. Re-run 02-05's own on-node pass once implementing; do not assume already-fast 02-05 (Mesh)
Server (Network) 969 738 8 7 true uncached fetch Convert Server's 78 independent fire-and-forget onMounted calls to useCachedResource (clean measurement — navSteps reach Server directly, no transit confound) 02-06 (Server and Home)
Web5 307 566 0 0 true remount storm KeepAlive wrapping — 0 RPC on revisit confirms the sub-cards' existing useCachedResource usage already prevents refetch; remaining cost is render/mount of the multi-card tree 02-04 (KeepAlive wrapping only)
Fleet 376 330 0 0 true already fast Left alone (D-02) — still gets KeepAlive wrapping 02-04 (KeepAlive wrapping only)
Chat (AIUI) unmeasured AIUI's own "connecting" overlay stayed up long enough on this node that the harness's close-button click never resolved within its retry budget (.chat-close-btn timeout, 20s) in run 0; runs 12 timed out even earlier at 6.6s once the harness's stuck-state recovery kicked in. This is itself a data point worth carrying into 02-07: AIUI's connection/handshake latency on real hardware is materially longer than on the dev workstation 02-07 (Chat/AIUI)
AppDetails (secondary) 1040 1204 2 1 true uncached fetch Convert loadBitcoinSync()/loadCredentials() to keyed useCachedResource (clean measurement — navSteps transit Apps, not Home, no confound) 02-03 (secondary screens)
MarketplaceAppDetails (secondary) 1978 1793 14 12 true remount storm Convert to keyed useCachedResource; 13 of the 14 captured calls match the same Home-transit confound as Marketplace — only package.versions (the 14th call, firing later at +1459ms, well after the Home-mount burst settles) is trustworthy as this view's own fetch 02-03 (secondary screens)
CloudFolder (secondary) 5116 1212 0 0 true remount storm First visit is consistently ~5s (lazy route-chunk cold load, confirmed across runs); revisit drops to ~1.2s once the chunk is warm client-side but stays remount-bound (0 RPC either way) — convert to keyed useCachedResource for the folder listing regardless, per D-04's secondary-screen pattern 02-03 (secondary screens)
OpenWrtGateway (secondary) 3213 663.5 4 4 true remount storm RPC count is confounded by the Server-transit artifact above — the captured methods are an exact subset of Server.vue's own call set, not evidence of OpenWrtGateway's own fetch pattern. Re-verify with DevTools before deciding whether this page needs its own useCachedResource conversion 02-03 (secondary screens)
Wallet / send flow (Home wallet card → SendBitcoinModal) 735 2607 0 0 true remount storm Anomaly worth flagging, not hiding: revisit (2607ms median) is consistently slower than first visit (735ms median) across all 3 runs (2562/2619/2607 vs 857/735/607) — the modal always fully remounts (BaseModal's v-if) with zero RPC either time, so the extra ~1.9s on reopen is pure client-side cost (fee/balance recompute, store re-subscription, or similar) that should be profiled directly with DevTools during 02-03, not assumed away 02-03 (secondary screens)

Ranked Fix Order

Worst-revisit-first, main tabs and secondary screens together (medians from the table above; rows excluded, listed separately below):

  1. Wallet-send — 2607ms (secondary/modal; anomalous revisit > first-visit, see note above)
  2. Marketplace — 2033ms (main tab; RPC evidence confounded, see caveat)
  3. MarketplaceAppDetails — 1793ms (secondary; RPC evidence confounded, see caveat)
  4. CloudFolder — 1212ms (secondary; first-visit-dominant, ~5s cold chunk load)
  5. AppDetails — 1204ms (secondary; clean uncached-fetch evidence)
  6. Discover — 1083ms (secondary; remount storm)
  7. Server — 738ms (main tab; clean uncached-fetch evidence)
  8. OpenWrtGateway — 663.5ms (secondary; RPC evidence confounded)
  9. Web5 — 566ms (main tab; remount storm, 0 RPC)
  10. Home — 390ms (main tab; already fast)
  11. Fleet — 330ms (main tab; already fast)
  12. Apps — 267ms (main tab; already fast)
  13. Cloud — 247ms (main tab; already fast)

Unmeasured: Mesh, Chat — see their rows above for why, and do not slot them into this ranking as if they were fast.

Tracer tab for plan 02-02: among main tabs specifically (02-02's actual scope — wallet-send and the secondary screens are not tabs), Marketplace is the worst-performing surface (2033ms revisit) and is also the surface the user named first ("often app store" — D-09). It is the tracer pick for 02-02, consistent with the plan set's existing "02-02 tracer/app store" naming.

Surfaces Left Alone (D-02)

These measured already fast on real hardware — no useCachedResource conversion is planned for their data layer. Per D-01 they still get wrapped in <KeepAlive> as part of 02-04's shared Dashboard.vue restructure (scroll/animation-state persistence is free and harmless even for an already-fast view), but that is template plumbing, not a per-view data-caching change, so they are listed here rather than under any per-surface fix plan.

Surface First visit (ms) Revisit (ms) Revisit RPC Why left alone
Home 354 390 1 Sub-400ms revisit with a single trivial system.get-hostname ping (not view-specific wallet data); wallet figures render from already-reactive store state
Apps 359 267 1 Sub-300ms revisit, single trivial ping; confirms RESEARCH.md's prediction that Apps.vue's data is WebSocket-pushed, not per-mount-fetched
Cloud 463 247 0 Sub-250ms revisit, zero RPC — confirms Cloud.vue's existing useCachedResource usage (peersResource/countsResource) is already preventing refetch
Fleet 376 330 0 Sub-350ms revisit, zero RPC

Corrections to Prior Research

ContainerAppDetails.vue dead-code claim — CONFIRMED, stronger than RESEARCH.md stated. RESEARCH.md's Assumptions & Flagged Items (FA-B) reported the planner's grep found "only a self-referential comment inside the file itself." Re-running the identical grep this session found zero matches at all, including no self-reference:

$ grep -rn "ContainerAppDetails" neode-ui/src
(no output)

The file exists at neode-ui/src/views/ContainerAppDetails.vue (351 lines) but has no importer anywhere in neode-ui/src and no route entry in neode-ui/src/router/index.ts (verified directly against the full route table read for this plan's Task 1). It is unreachable dead code.

Impact: RESEARCH.md named ContainerAppDetails.vue:169-172's await loadContainer(); await loadLogs(); await loadHealthStatus() as the phase's one confirmed serial-RPC-waterfall fix target (Pattern 3). Since the file is unreachable, no plan in this phase should spend effort parallelizing it — doing so would touch dead code for zero user-visible benefit. More importantly: no other surface in the measured D-09 set shows a serial-waterfall signature. Every surface in this findings doc with 2+ RPC calls on revisit (Marketplace, Server, AppDetails, MarketplaceAppDetails, OpenWrtGateway) shows maxConcurrentRpc at or near its total call count (7 of 8, 12 of 13, 12 of 14, 1 of 1-2, 4 of 4) — i.e. already parallel, not serial. serial RPC waterfall is not used as a Primary Cause anywhere in the Per-Surface Findings table above because the measured evidence does not support it for any reachable surface. This is the single most consequential correction this profiling pass makes to RESEARCH.md: D-13's Promise.all/Promise.allSettled parallelization pattern has no live target to apply to in this phase's D-09 surface set.

Owning Plans

Surface Owning plan
Home 02-04 (KeepAlive wrapping only — see Surfaces Left Alone) / 02-06 names Home in its title but the profiling shows no data-layer work is needed there
Apps 02-04 (KeepAlive wrapping only)
Marketplace 02-02 (tracer/app store)
Discover 02-02 (tracer/app store)
Cloud 02-04 (KeepAlive wrapping only)
Mesh 02-05 (Mesh)
Server 02-06 (Server and Home)
Web5 02-04 (KeepAlive wrapping only)
Fleet 02-04 (KeepAlive wrapping only)
Chat 02-07 (Chat/AIUI)
AppDetails 02-03 (secondary screens)
MarketplaceAppDetails 02-03 (secondary screens)
CloudFolder 02-03 (secondary screens)
OpenWrtGateway 02-03 (secondary screens)
Wallet / send flow 02-03 (secondary screens)

The shared foundation both 02-02 and every other per-surface plan depend on — the Dashboard.vue nested-<router-view> KeepAlive restructure and the useCachedResource onActivated extension — is owned by 02-04, per RESEARCH.md's own recommendation to land that shared work before converting individual views.

Results

Method: Re-ran neode-ui/e2e/perf/{surfaces,measure,surface-perf.spec}.ts unmodified (no harness file was edited to produce these numbers) against archi-dev-box — the same target, same runs: 3, same ARCHY_PERF_OUT contract as the baseline. Run header: baseUrl: "http://archi-dev-box", commit: "3ee20430", runs: 3, taken 2026-07-31T01:27:30.384Z (baseline was taken 2026-07-30T10:30:40.048Z — a different time of day on the same physical node; see the timing-variance note under Outstanding). 15/15 rows present (same count as baseline); Mesh and Chat are again unmeasured, for reasons recorded per-row below.

A new methodology finding, discovered and independently cross-verified this run (not present at baseline time because no <KeepAlive> existed then): SURFACES.rootSelector for every main tab is the generic class .view-container, which DashboardRouterView.vue falls through onto every routed view's own root (<component :is="Component" :class="… 'view-container flex-none'" /> — see dashboardViewWrappers.ts/DashboardRouterView.vue). Before this phase, only one view was ever mounted at a time, so .view-container was unambiguous. Now that multiple main tabs are simultaneously KeepAlive-cached (up to KEEP_ALIVE_MAX), more than one .view-container-classed element can coexist in the document at once, and measure.ts's stampRoot/readRootProbe (document.querySelector, first DOM match) can silently stamp/read a different cached instance than the one actually being tested — confirmed directly by DOM inspection (document.querySelectorAll('.view-container').length returning 2 on a fresh visit to Marketplace, and the same call's return order changing between the stamp step and the read-back step as Vue relocates deactivated subtrees into its internal KeepAlive storage). This was not something I could fix by editing the harness (out of this task's files_modified, and re-running the historical baseline commit to keep the pair comparable would be invasive for a field — remounted — whose baseline value is trivially true everywhere pre-KeepAlive regardless of selector precision). Instead, for every main-tab row I independently re-verified remounted with a corrected, harness-independent method: stamp/read the specific .view-container ancestor of the surface's own visible contentSelector match (getBoundingClientRect/offsetParent, since KeepAlive's inactive cached instances are not laid out on screen) rather than the first DOM match. Reproduced twice for Server specifically to rule out a fluke. The "Remounted (verified)" column below is this corrected reading; the "Remounted (raw)" column is the harness artifact's own (sometimes confounded) field, kept visible rather than silently replaced.

Surface Kind Baseline revisit ms After revisit ms Baseline RPC After RPC Remounted (raw, baseline→after) Remounted (verified) Verdict
Home main-tab 390 395 1 1 true → true false (survives) improved — remount fixed; ms was already fast (D-02) and unaffected
Apps main-tab 267 184 1 1 true → true false (survives) improved — faster ms and remount fixed
Marketplace main-tab 2033 683 13 8 true → true false (survives) improved — 66% faster and remount fixed; RPC count still confounded by the Home-transit artifact 02-01 documented, unchanged this run
Discover secondary (cache-registered) 1083 1257 1 1 true → true not independently re-verified regressed — revisit got slower; raw remounted unchanged
Cloud main-tab 247 129 0 0 true → true false (survives) improved — faster ms and remount fixed
Mesh main-tab unmeasured.mesh-status-grid stayed hidden 20s across all 3 runs; device not reporting connected in this window, identical reason to baseline
Server main-tab 738 849 8 2 true → true true (genuinely remounts) regressed — slower ms AND the KeepAlive promise is not delivered for this tab, confirmed by two independent reproductions of the corrected probe. RPC count did drop (8→2, real useCachedResource benefit), but the surface still fully remounts on revisit
Web5 main-tab 566 709 0 0 true → true false (survives) regressed on ms despite remount being fixed — a split signal (0 RPC either time, so the added ~140ms is pure client-side re-render/re-activation cost on an instance that isn't actually being destroyed); flagged for direct profiling, not fixed here
Fleet main-tab 330 1054 0 0 true → true false (survives) regressed, prominently — 3.2× slower on revisit despite the instance surviving and zero RPC either time; the same split-signal pattern as Web5 but much larger. Reached via a 2-hop chain through Web5 first, so some of this may be transition-chain noise, but the raw number is recorded as measured, not adjusted
Chat main-tab unmeasured — but for a different specific reason than baseline: baseline's block was the AIUI connecting-overlay outliving the close-button click budget; this run's block is the disk-usage HealthNotifications.vue toast (see below) intercepting .chat-close-btn for the same reason it now interferes elsewhere. Recorded as unmeasured either way, per the plan's own rule that an unmeasured surface is never presented as improved
AppDetails secondary 1204 1510 2 1 true → true not independently re-verified (expected to remount; D-04 doesn't cache secondary screens) regressed — revisit got slower despite one fewer RPC call
MarketplaceAppDetails secondary 1793 1532 14 8 true → false not independently re-verified improved on ms; the raw remounted: false reading is very likely the same rootSelector-ambiguity artifact described above (its navSteps transit a KeepAlive-cached Marketplace instance immediately before reaching the detail page) rather than evidence this secondary screen is now incorrectly instance-cached — flagged, not asserted either way. RPC count improvement is still confounded per 02-01's original note (13 of 14 baseline calls were Home-transit bleed-through; only package.versions was trustworthy)
CloudFolder secondary 1212 985 0 0 true → true not independently re-verified improved — faster ms, 0 RPC both times as before
OpenWrtGateway secondary 663.5 1148 4 2 true → true not independently re-verified regressed — revisit got slower; RPC count still confounded per 02-01's original note (captured methods are a subset of Server's own call set)
Wallet / send flow secondary (modal) 2607 2556 0 0 true → true not independently re-verified (modal always remounts via v-if, by design) unchanged — the anomaly 02-01 flagged (revisit slower than first-visit, pure client-side cost, 0 RPC) persists; no plan's files_modified touched SendBitcoinModal.vue (see Outstanding)

Outstanding

  • Server.vue does not survive a tab round-trip on real archi-dev-box hardware, despite being registered in KEEP_ALIVE_PATHS since 02-04 and 02-04's own Task 3 checkpoint reporting it as approved. Confirmed genuinely (not a measurement artifact) via two independent reproductions of a corrected, harness-independent remount probe. This directly violates this plan's own must-have truth ("every main-tab path registered in KEEP_ALIVE_PATHS has After remounted false"). Root cause not investigated here — Task 2's scope is measurement, not per-view debugging, and this is a pre-existing 02-04 behavior, not something this plan's own files touched. Needs a follow-up plan or a targeted fix to Server.vue/its KeepAlive wrapper registration before PERF-02's per-tab claim can be considered fully verified for this specific tab.
  • Web5 and (especially) Fleet revisit ms regressed significantly despite their component instances now genuinely surviving the round-trip (confirmed via the corrected probe) and zero RPC calls either visit. This is a split signal: the KeepAlive architecture is working (no remount, no refetch) but something client-side is adding real wall-clock cost on reactivation — Fleet's 330ms→1054ms (3.2×) is the more severe of the two. Not profiled or fixed here; flagged for direct DevTools/Performance-panel investigation, the same class of work 02-01 already flagged for Wallet-send's anomaly and never resolved.
  • Discover, AppDetails and OpenWrtGateway all regressed on revisit ms without an independent remount re-verification (not main tabs subject to the plan's KeepAlive must-have truth, so not re-probed under time constraints); OpenWrtGateway's RPC count is still confounded per 02-01's original note (its captured methods are a subset of Server's own call set from the Server-transit in its navSteps) and needs DevTools re-verification before treating the RPC drop as this screen's own fix rather than confound noise shifting.
  • MarketplaceAppDetails's raw remounted: false reading is very likely a rootSelector- ambiguity artifact, not evidence this secondary screen picked up incidental instance caching — flagged, not independently re-verified given time constraints. If a future plan needs to confirm D-04 (secondary screens are NOT instance-cached) still holds for this screen specifically, re-run the corrected probe method documented above against it directly.
  • Wallet-send's anomaly (revisit slower than first-visit, 0 RPC either time) is still unaddressed — 02-03 reported it as an unplanned-item gap (owned by 02-03 per this findings doc, but SendBitcoinModal.vue never appeared in any plan's files_modified). Carried forward unresolved.
  • CloudFolder.vue's residual TTL gap: cloudStore.navigate() (cloud.ts) still always re-fetches on revisit — it just doesn't block paint. 02-03 explicitly left this as a gap (outside its files_modified) rather than bolting on a second, parallel cache. Still open.
  • PeerFiles.vue was found by 02-03 to not actually use useCachedResource (contrary to that plan's own read_first assumption) and force-refetches unconditionally on every mount with the same loading/refreshing conflation bug 02-03 fixed elsewhere. Flagged by 02-03, not fixed by any plan since. Still open.
  • /dashboard/settings remains withheld from KEEP_ALIVE_PATHS (02-04's own deliberate exclusion, unaudited risk — SystemDangerZone.vue's reboot poll, several onMounted-only fetches in its child sections). Not part of this phase's scope to resolve; carried forward as a known gap for whichever future plan audits Settings.
  • ContainerAppDetails.vue reconfirmed fully unreachable dead code (02-01, re-confirmed 02-03). A candidate for a follow-up cleanup deletion, not part of this phase.
  • archy-x250-dev (the dev pair's second node) was offline for both this plan's deploy (Task 1) and this re-measurement — tailscale status showed it "offline, last seen 2h ago" at Task 1 and unchanged when re-checked before Task 2. Only archi-dev-box received this phase's build and was measured. D-11's verification target is archi-dev-box specifically, so this does not block the plan, but the second dev-pair node has not received this phase's changes at all and should get the same --frontend-only deploy once it's back online.
  • Chat/Mesh remain unmeasured, same as baseline for Mesh (device not reporting connected); Chat's specific blocking cause changed from an AIUI-connection timeout (baseline) to a disk-usage toast intercepting the close button (this run) — both are environmental/UI-affordance issues rather than something either run's numbers can speak to. The disk-usage toast (HealthNotifications.vue, no pointer-events: none on its .fixed.right-4.z-[200] wrapper) intercepting clicks elsewhere on the page is itself worth a follow-up fix independent of this phase — the node's disk is genuinely at 85% right now, so the toast is a real, current, recurring condition, not a one-off.
  • Baseline vs. after timing-variance caveat: the baseline was taken at 10:30 local time; this after-run at 01:27 the next day — different system load on the same physical, multi-service node (85% disk, live containers, this same box also serving as the build machine) may contribute to some of the regressed rows above independent of any code change. This is recorded as an interpretive note, not used to soften any verdict above — every regressed row is reported as regressed with its measured numbers. A same-time-of-day re-run would help disambiguate real regression from environmental noise for Discover/Server/Web5/ Fleet/AppDetails/OpenWrtGateway.
  • Assumptions & Flagged Items from the plan set not yet settled by this run: FA-D (KEEP_ALIVE_MAX) was settled in Task 1 (see its SUMMARY — confirmed at 6 via an on-device memory reading). FA-B (ContainerAppDetails.vue) — see the dead-code note above. FA-E (AIUI source) — settled by 02-07 (source located, D-14 landed upstream).

Addendum: Task 3 Checkpoint Follow-Up (2026-07-31, post-first-pass)

The user's first Task 3 pass reported "otherwise it's getting much better" plus four specific issues. Each was investigated on archi-dev-box directly (not guessed), root-caused via git history, and — where it traced to a phase-2 commit — fixed. No new plan was created per standing direction; this addendum records the investigation and its outcome.

Real-node phase-2 regression: first visit to Cloud, no folder opened on click

Confirmed, root-caused, and fixed — verified 5/5 fresh sessions against the redeployed build. Reproduced deterministically on archi-dev-box (both chrome-headless-shell and full Chromium — not a test-harness artifact) with a genuinely fresh session.

  • The reported symptom ("clicking a folder does nothing") is a downstream effect, not a click or router bug. Direct instrumentation proved: the click event fires (confirmed via a raw DOM listener attached to the exact clicked node), router.push({name:'cloud-folder',...}) is called (confirmed by patching the live $router instance), but the returned promise never settles — because Vue Router awaits the target route's async component, and that dynamic import() itself never resolves.
  • Confirmed via manual import() calls from the page console: after visiting Cloud once, importing any lazy route chunk (Fleet, CloudFolder, AppDetails — unrelated views) hangs identically; before ever visiting Cloud, the same imports resolve in ~300ms. Not chunk-specific.
  • Traced to exactly one permanently-pending network request: GET /app/filebrowser/api/resources/Photos, fired from Cloud.vue's fetchCounts() on mount. Confirmed hung via request-lifecycle tracking (request fires, neither requestfinished nor requestfailed ever fires, still pending after 10s). The identical request, issued manually with a fresh token completely outside of Cloud.vue, returns in 29ms — ruling out File Browser or the network path itself. Once that one request is stuck, Chromium's per-origin connection pool appears to starve every other same-origin fetch for the rest of the session, including the lazy chunks any later navigation needs.
  • Fix #1 applied (fix(02-08), commit e1a3f31a, neode-ui/src/views/Cloud.vue): gave Cloud.vue's syncOnEntry() the same fresh-mount guard already used in Home.vue/Web5.vue/Mesh.vue/Server.vue (02-04 exempted Cloud on the reasoning each individual resource is staleness/inflight-deduped — true per-resource, but the two back-to-back onMounted+onActivated passes still doubled loadPeerFiles()'s full per-peer content.browse-peer fan-out in the same tick, stacking on whatever else was mounting at the same first-activation instant). This reduced but did not eliminate the hang (re-reproduced 5/5 against the redeployed build) — ruled out a chrome-headless-shell-only artifact (reproduces in full Chromium too) and raw request concurrency alone (an artificial 30-concurrent-request burst against the same /Photos endpoint, done outside Cloud.vue, completed in 179ms, no hang).
  • Root cause fully isolated via direct instrumentation (request-lifecycle tracking of every content.browse-peer call during a fresh Cloud mount): 13 of 14 concurrent content.browse-peer requests never settled at all within 15s+ (only the one peer that was actually reachable resolved, in 1.4s). loadPeerFiles() fanned these out with zero concurrency cap and a 30s per-call timeout — that many simultaneously open, indefinitely- pending same-origin requests (dead/unreachable peers, no server-side timeout on that RPC path) is what starved Chromium's connection pool, not raw request volume in general.
  • Fix #2 applied (fix(02-08), commit 8fe6217b, neode-ui/src/views/Cloud.vue): capped the content.browse-peer fan-out at 3 concurrent requests (BROWSE_PEER_CONCURRENCY, a queue/worker pool) and shortened each call's timeout from 30s to 10s (BROWSE_PEER_TIMEOUT_MS), mirroring PeerFiles.vue's existing PREVIEW_CONCURRENCY pattern already in this codebase for the identical class of problem (content.preview-peer's own fan-out). A timed-out/failed peer already resolved silently through resources.ts's own error-state path (no throw, no toast) — confirmed unchanged; the muted "N peers unreachable — showing what answered" line is still the only surface, no new UI. Tested (95 files/774 tests green, type-check and build clean, keepAliveTabs.test.ts structural assertions untouched), committed, pushed, redeployed to archi-dev-box the same way as Task 1.
  • Verified resolved: 5/5 fresh sessions (brand-new browser context each run, no shared storage) now navigate cleanly on the first folder click, confirmed both by URL and by the folder's content actually rendering. Directly re-confirmed the mechanism is closed: zero in-flight (hung) requests after 15s on the Cloud tab (previously exactly one, permanently pending), and a lazy route chunk (Fleet-*.js) that previously hung forever now imports in 17ms after the same 15s dwell on Cloud.
  • Closed. Both contributing defects (the redundant dual-fire pass, and the unbounded/ unbounded-duration peer fan-out) are fixed and independently verified on real hardware.

Classification of the other three reported issues — all pre-existing, none phase-2

All three traced via git log/git merge-base --is-ancestor against a75b6709 (the commit the 02-01 baseline was taken at, i.e. the last commit before any phase-2 UI change landed). None of the implicated files or functions were touched by any phase-2 commit; no fix was implemented for any of these three, per standing direction (captured separately as phase-1/general UX work).

  1. Paid Files: clicking a picture opens it in a browser tab instead of the app's lightbox. neode-ui/src/views/Cloud.vue viewPaidItem() (~line 470-493): for any non-audio purchased item it does window.open(url, '_blank', 'noopener') on a blob URL — it never calls MediaLightbox.vue (neode-ui/src/components/cloud/MediaLightbox.vue) at all, unlike the My Files / Peer Files rows' handlePreview() paths, which do route into the lightbox. Introduced in f3393581 ("fix(content): double-pay is now impossible + purchases auto-file + Paid Files tab", 2026-07-22 — confirmed an ancestor of a75b6709 via git merge-base --is-ancestor). Pre-existing, not phase 2.
  2. Picture-in-picture on a video doesn't close the lightbox; the transition should be fluid. neode-ui/src/utils/pip.ts's togglePip() only calls video.requestPictureInPicture()/exitPictureInPicture() — it never emits a close event or touches MediaLightbox.vue's (neode-ui/src/components/cloud/MediaLightbox.vue, PiP button at ~line 21-31, close() at ~line 233) own visibility state, so the lightbox backdrop simply stays open behind the PiP window with no reaction and no transition at all. Introduced in f72d4b92 ("feat(content): seller-picked payment methods + music always in the bottom bar + video PiP", 2026-07-23 — zero commits touch MediaLightbox.vue or pip.ts between a75b6709 and HEAD, confirmed via git log a75b6709..HEAD --). Pre-existing, not phase 2.
  3. Some surfaces lack loader states where an open genuinely takes time. Spot-checked rather than exhaustively swept (a full sweep of every secondary-screen/modal open across the app is its own piece of work). The clearest concrete instance found: neode-ui/src/views/Cloud.vue viewPaidItem() (same function as #1 above) makes a content.owned-get RPC call with a 60-second timeout and shows no loading indicator at all while it resolves — paidLoading (line 466) only covers the list fetch (content.owned-list), not the per-item open. Clicking a Paid Files row shows nothing until the new tab appears (or fails silently). Same commit (f3393581, 2026-07-22) as #1 — pre- existing, not phase 2. Other candidates were not individually verified in the time available; CloudFolder.vue's native file-grid path does already show a loading skeleton (neode-ui/src/components/cloud/FileGrid.vue line 4), so that one specific secondary screen is not part of this finding.
  4. Picture-in-picture must survive tab changes or video buffering pauses; doesn't seem to. Two parts, classified separately:
    • Tab-change part, checked against phase 2's lifecycle changes specifically — not implicated. Neither Cloud.vue, CloudFolder.vue, nor MediaLightbox.vue (neode-ui/src/components/cloud/MediaLightbox.vue) defines an onDeactivated hook at all (zero matches for onDeactivated in any of the three) — 02-04 never touched this file and added no teardown that pauses, resets, or unmounts video/lightbox state on tab deactivation. MediaLightbox is a <Teleport to="body"> child rendered from inside Cloud.vue's template (v-if="lightboxIndex !== null"), so when Cloud.vue (KeepAlive- cached since 02-04) deactivates on a tab switch, Vue's own KeepAlive machinery recursively deactivates that whole subtree, including the teleported lightbox/video, moving its DOM into KeepAlive's internal (not document-attached) storage container. Whether Vue's exact move semantics disconnect the <video> element from document (which would explain a native PiP session ending, per browser spec) was not conclusively provable from source reading alone. What IS provable, and settles the phase-2 question either way: pre-phase-2, switching tabs fully unmounted Cloud.vue and destroyed its video element outright (zero <KeepAlive> existed anywhere before 02-02) — a guaranteed PiP-ending event, strictly worse than or equal to whatever KeepAlive's deactivation does now. Phase 2 could not have regressed "PiP survives a tab change" because that behavior never existed before phase 2 to regress from. Pre-existing/never-supported, not a phase-2 regression — a real UX gap worth phase 1's UIFIX-05 attention, but not something 02-04 broke.
    • Buffering-pause part — pre-existing, and there is no custom handling to point at. MediaLightbox.vue's <video> element (~line 77-87) is a bare <video controls autoplay @dblclick @error> — grepping pause/waiting/stalled across MediaLightbox.vue and useAudioPlayer.ts finds zero listeners on the video element for those events. There is no app-level rebuffering/PiP-continuity logic to have regressed; PiP-during-rebuffer behavior here is entirely native browser/HTML5-video default behavior, untouched by any app code in any phase. Pre-existing (by absence), not phase 2.

Server KeepAlive Root Cause (gap closure)

Named cause (one sentence): Server.vue's (and, discovered mid-investigation, Web5.vue's) component instance genuinely survives every tab round-trip on archi-dev-box exactly like every other registered KEEP_ALIVE_PATHS tab — the "remounts" verdict this plan set out to root-cause is a confirmed measurement artifact of the remount probe's own selector, not a real KeepAlive or component-lifecycle defect; the underlying architecture (DashboardRouterView.vue / dashboardViewWrappers.ts / keepAliveRoutes.ts) works correctly for these tabs and needs no fix.

Method

Per D-10's cheapest-discriminator-first order:

Step A (jsdom reproduction, no device): Extended keepAliveLifecycle.test.ts's pattern with a new test mounting the REAL DashboardRouterView + REAL Server.vue (both a directly-imported and an () => import(...)-based async-component route, matching production's own route definition exactly) at /dashboard/server, doing an away-hop to /dashboard/settings and back, comparing findComponent(Server).vm.$.uid (Vue's internal per-instance monotonic id) before and after. Did not reproduceuid was identical both times in every variation tried (bare router, async-import router, full 10-surface KEEP_ALIVE_PATHS sequence with real LRU pressure and real Web5.vue mounted alongside). This positively rules out a pure client-side/router bug reproducible without a real backend or real network timing (suspect 2's own framing: "a device-only failure would explain why Step A passes while the device does not" — confirmed true, but not for the reason originally guessed).

Step B (on-device instrumented probe): Built neode-ui/e2e/perf/keepalive-remount-probe.spec.ts — a standalone, committed, re-runnable Playwright spec (imports KEEP_ALIVE_PATHS paths as a literal re-declared list per the e2e tsconfig's alias gap; reuses SURFACES' navSteps/ contentSelector/rootSelector read-only) that logs into archi-dev-box and, for every registered path, does visit → stamp → away → return → read, with three added instruments:

  1. Instance-identity via __vueParentComponent.uid (confirmed unconditionally set by node_modules/@vue/runtime-core's mountElement, not gated behind __DEV__) — returned null for every reading against the deployed build. Confirmed this is the production bundle stripping the devtools hook (__VUE_PROD_DEVTOOLS__ defaults off), not a probe bug: the property is genuinely absent from every root, survivors and non-survivors alike, on the real deployed build, while the identical code path DOES populate it under vitest's dev-mode Vue build (used to confirm Step A). This instrument was inconclusive on-device by construction — an honest limitation, not a finding either way.
  2. page.on('pageerror')/page.on('console'), captured for the ENTIRE session (not just per-surface — session-wide capture added after per-surface listeners risked missing a delayed/async error) — across four independent full-session runs, zero errors or warnings were ever attributed to Server or Web5's activation/deactivation. Suspect 2 (a runtime error tearing down the cached subtree) has no supporting evidence.
  3. document.querySelectorAll('.view-container').length + location.pathname after every hop — this instrument caught a REAL bug in the probe's own first iteration (not a hypothesis): the initial version's waitFor({state:'visible'}) on the shared, generic .view-container [data-controller-container] selector could resolve BEFORE the navigation actually landed, because the PREVIOUS tab's own matching content was still visible — pathname logging caught this directly (Server's afterVisit.pathname read back /dashboard, not /dashboard/server). Fixed by gating on page.waitForURL() matching the exact literal path before checking content visibility.

Suspects eliminated by direct measurement (not by inspection alone):

  • Suspect 3 (LRU eviction, KEEP_ALIVE_MAX=6): instrument 3's viewContainerCount trace, combined with the exact SURFACES-order navigation sequence, shows no NEW distinct KEEP_ALIVE_PATHS entry is added between any surface's own away-then-return hop — the cap cannot be the mechanism evicting Server/Web5 specifically during their own isolated round trip. Eliminated.
  • Suspect 4 (route.path mismatch): instrument 3's pathname logging confirms location.pathname matches the surface's literal registered path exactly on every visit and return, for all ten paths including Server and Web5, across every run. Eliminated.
  • Suspect 5 (include comma-split matching): none of the ten KeepWrap:<path> names contain a comma; Vue's own matches() only splits on commas, so this holds identically and vacuously for every registered path — structurally proven, not just measured. Eliminated.
  • Suspect 2 (Server-specific runtime error): zero captured errors/warnings across four independent full-session-captured device runs, plus a real jsdom reproduction attempt covering the exact real components in the exact real sequence found no error either. Not supported by any evidence gathered.

Suspect 1 (probe measures DOM-element identity, not component-instance identity) — CONFIRMED, with direct proof, and refined beyond its original framing. The generic .view-container [data-controller-container] / .view-container pair — shared by Server, Web5 AND Fleet, and structurally shared by every OTHER main tab too, since DashboardRouterView.vue applies the view-container class via fallthrough to every non-full-bleed routed view's own root (:class="isFullBleedPath(route.path) ? undefined : 'view-container flex-none'") — cannot reliably distinguish "the tab actually visible on screen right now" from "a different KeepAlive-cached tab whose root happens to still be connected to the document" once more than one main tab has been visited (the NORMAL, intended state — that is the entire point of KeepAlive). Every .view-wrapper is position: absolute; inset: 0, so ANY currently-mounted alternative view's root reports a non-zero getBoundingClientRect() and a non-null offsetParent — the exact "visible" check the corrected 02-08 method (and this plan's own Step-B probe, initially) used — REGARDLESS of whether that view is the true foreground tab. Array.prototype.find()'s first-DOM-order match has no way to tell these apart.

Two additional discoveries sharpened this beyond a hypothesis into direct, decisive proof:

  • /dashboard/settings (the away tab EVERY round trip in this probe, and in measure.ts's own NEUTRAL_SELECTOR convention, uses) itself matches the exact same generic selector. Settings.vue renders AccountSection (→ AccountInfoSection.vue, has data-controller-container) and SystemSection (→ KioskDisplaySection.vue, has data-controller-container) unconditionally — no tabs/accordion gating — and Settings' own root ALSO gets the view-container fallthrough class like every other non-full-bleed route. Its leave-transition keeps that DOM genuinely connected and "visible" by the offsetParent/rect check for the transition's full duration while the target's enter-transition is already progressing.
  • Adding an authoritative, independent signal — document.elementFromPoint() at the viewport center (real hit-testing, respects actual stacking/z-index, unlike the offsetParent heuristic) — directly contradicted the naive method's "remounted" verdict. Across the runs where this signal resolved to a definite answer at all (it is null when the exact center pixel doesn't land inside a .view-container-classed element, which happens often enough to not be usable as the SOLE signal): it read true (survived) for Server in one run where the naive first-DOM-match method read false, and true (survived) for Web5 in a separate run where the naive method ALSO read false — direct, same-round-trip contradictions between "what's actually rendered on screen" and "what the naive selector-match probe reports." It never once read false for either Server or Web5 across all decisive runs. A companion diagnostic — enumerating every .view-container-matching root in the document at read-back time with its own stamp mark — independently confirmed the same thing directly: one read for Web5 found the ORIGINAL stamped root still connected: true and visible: true (by the offsetParent check), simply not the one the naive .find() picked first.

Verdict

Server.vue's and Web5.vue's component instances survive a tab round-trip on archi-dev-box — proven directly via an authoritative, hit-test-based signal that independently contradicts the "remounted" reading the naive selector-based method (both 02-08's and this plan's own first-pass correction) produces for these two specific tabs. This is not a guess or an absence-of-evidence argument: it is a positive, repeated (elementFromPoint agreed with survival in every run where it resolved a verdict at all — zero contradicting readings) observation that the actual foreground, user-visible element at read-back time is the SAME element this probe stamped at visit time.

Per the plan's own explicitly anticipated branch: the earlier "genuinely remounts" reading (in 02-08 and in this plan's own early iterations) was a probe artifact, not a real defect. Server and Web5 are — like Fleet — surfaces whose contentSelector/rootSelector happens to be the fully generic pair shared by every KeepAlive-cached main tab, which makes THEIR OWN measurement uniquely vulnerable to this artifact even though the underlying KeepAlive architecture treats them identically to every reliably-measured tab (Home, Apps, Marketplace, Discover, Cloud, Chat — all of which have their own unique CSS class and never hit this ambiguity).

Fleet is NOT independently re-confirmed either way here (out of this gap's named scope — 02-08 already reported it surviving with a large timing regression, unaffected by this finding); elementFromPointSurvived never resolved to a definite verdict for Fleet across the runs performed (its viewport center did not land inside a matching root at read-back time), so this finding neither confirms nor overturns 02-08's existing Fleet verdict.

Intended fix (Task 2): No change to DashboardRouterView.vue, dashboardViewWrappers.ts, keepAliveRoutes.ts or Server.vue's KeepAlive/lifecycle wiring — the architecture already delivers PERF-02's instance-caching promise for this tab. Task 2 lands Tests 14 (component- instance-identity assertions via findComponent(...).vm.$.uid, which sidesteps the generic- selector ambiguity entirely by using Vue's own component tree instead of a CSS selector) as a permanent regression pin, confirms they pass immediately against the CURRENT, unmodified code (itself the proof), and updates this section with the pre-existing-code pass observation. No build/deploy/redeploy step is needed since nothing in neode-ui/src changes.

Task 2 outcome (no-op branch, per this plan's own explicitly anticipated path)

Landed four new tests in keepAliveLifecycle.test.ts's new 02-09 gap closure describe block, measuring component-instance identity via vm.$.uid (Vue's own internal per-instance id) rather than any CSS selector:

  • Test 1 (the gap): mounts the REAL DashboardRouterView + REAL Server.vue at /dashboard/server, away-hops to a synthetic /dashboard/settings, returns, and asserts findComponent(Server).vm.$.uid is IDENTICAL before and after. Passed immediately, first try, against the unmodified code — no RED phase was possible or expected, because this is the no-change branch: the plan's own instruction ("this branch is only available on positive proof of survival... land Tests 1-4, they will pass immediately, which is itself the pin") describes exactly this outcome. A failing-before-fix observation does not exist here because there was never a fix.
  • Test 2 (no collateral damage): the same round trip through the REAL Web5.vue (the other surface this investigation implicates) plus a synthetic second tab, both keeping their instance-uid / mount-count at exactly 1 across the round trip. Passed immediately.
  • Test 3 (registration is really the include list): every keepAliveIncludeNames() entry is a KeepWrap:<path> name for a registered path, contains no comma, and /dashboard/server's and /dashboard/web5's wrapper names are both present. Passed (already true, unconditionally, from the existing derivation in dashboardViewWrappers.ts).
  • Test 4 (the bound stays bound): shouldKeepAlive still registers Server/Web5 and KEEP_ALIVE_MAX is still 6, unchanged. Passed — the pre-existing 02-04 eviction test in the same file (visiting more distinct registered tabs than KEEP_ALIVE_MAX...) is untouched and still green, confirming the cap itself was never in question.

Verification run: full npm test95 test files / 778 tests, all green (no regressions introduced by the new mocks added to this file's vi.mock('@/api/rpc-client', ...) Proxy fallback and new vi.mock('vue-i18n', ...), both additive). npm run type-check — clean. npm run build — succeeds (✓ built in 24.62s); no bundle-content grep was performed because, per the no-change branch, nothing in neode-ui/src differs from the last-deployed build, so there is no new string to look for and no reason to redeploy. keepAliveTabs.test.ts — confirmed byte-for-byte unmodified (git diff --stat empty) and still green (6/6 tests passing).

No deploy performed. D-15's dev-pair-only rule is satisfied vacuously: archi-dev-box already runs the build this investigation probed against (unchanged), and archy-x250-dev's status is unaffected either way by a change that never happened.

Re-measurement (gap closure)

Verdict on the environmental-noise theory: partially confirmed, partially refuted, by data — not left as a caveat. Wallet/send-flow's apparent regression clears as noise. Discover, Server, Web5, AppDetails and OpenWrtGateway do not clear: three independent runs, taken on three different days under three different (and progressively less disk-pressured) machine states, show a monotonically worsening median revisit time for all five. A confound that shrinks with less noise would regress toward the baseline as conditions improved; these five instead got worse as disk pressure genuinely eased, which is the opposite of what the noise theory predicts and points at a real, phase-2-introduced cost instead.

Run header and recorded conditions, all three runs

Baseline After Re-measure (this run)
takenAt 2026-07-30T10:30:40Z (10:30 local) 2026-07-31T01:27:30Z (01:27 local) 2026-07-31T10:41:34Z (06:41 local)
commit (artifact header) a75b6709 3ee20430 7c063a20*
runs 3 3 5
Disk (df -h /) not recorded numerically; FINDINGS.md's Outstanding section states "85% disk" qualitatively same node, same qualitative 85% note 79% (1.4T/1.8T used, 372G free) — confirmed ~118G freed since the after-run (core/target, image-recipe/build, caches deleted)
Load average not recorded not recorded pre-run 8.70/7.99/7.94; post-run 6.88/11.88/9.75
Concurrent activity not recorded live containers on the same multi-service/build box (per Outstanding) yes, materially: pre-run, a podman build of an unrelated app (BotFights 1.2.3) was actively consuming CPU on this same physical box (finished mid-run); post-run, other sessions on this shared box were running npm run build(vite), vitest, and a typeorm migration:run concurrently — confirmed via ps aux at both checkpoints

*The artifact's commit field is git rev-parse --short HEAD of the local working copy at the moment the spec ran, not the deployed bundle's commit — two docs-only/unrelated-app commits (d7fbba98 02-09 completion doc, 058d7609/002de661 BotFights 1.2.3, none touching neode-ui/src) landed locally between the deploy and the test run. The bundle actually served by archi-dev-box for this measurement is 3e3159fa (confirmed via scripts/deploy-history.log's 2026-07-31T09:59:44Z | main@3e3159fa | dirty=false entry, the last deploy before this run) — git diff --stat 3e3159fa..7c063a20 -- neode-ui/src is empty, so the discrepancy is cosmetic, not a measurement-validity problem.

Deploy performed before measuring, then blocked for the rest of the session: archi-dev-box's last frontend deploy before this plan was 8fe6217b (02-08's Cloud.vue fix) — predating the 02-review commits (57989dfc..fcbf50f6) that touch Web5/Discover/Server/OpenWrtGateway and predating 02-09's investigation entirely. Since four of this gap's six named surfaces are exactly the ones those review commits touch, a stale-bundle measurement would have been worthless, so Task 1 ran ARCHIPELAGO_TARGET=archipelago@archi-dev-box scripts/deploy-to-target.sh --frontend-only first, against a clean tree (git status --short showed no neode-ui changes at that moment), landing 3e3159fa, confirmed via the post-deploy health check (200 OK) and the deploy-history log entry above. Partway through this plan's execution the coordinator identified a shared-tree hazard that arose after that deploy completed: a concurrent security follow-up session and a BotFights session began leaving uncommitted edits in neode-ui/src/stores/resources.ts, composables/useCachedResource.ts, views/Cloud.vue, views/AppDetails.vue, and several other files — none of which existed at deploy time (the deploy's own dirty=false proves it), but any second deploy this session would ship that in-progress, unreviewed work to the node alongside anything this plan might fix. Per the coordinator's explicit instruction, no further deploy was performed this session — this is why every confirmed regression below is recorded as an accepted deviation rather than fixed: the plan's own "fixed" branch requires a deploy-and-re-measure step to prove the number moved, and that step is unavailable this round.

Harness run reliability note: the first ARCHY_PERF_RUNS=5 attempt hit the harness's own hardcoded test.setTimeout(20 * 60 * 1000) (surface-perf.spec.ts line 54, sized for "15 surfaces x 3 runs") while the BotFights podman build above was consuming CPU — it aborted after 9/15 surfaces (through fleet), with every remaining surface (chat through wallet-send) failing Target page, context or browser has been closed — a harness-timeout artifact, not a measurement, and not written into the committed artifact as if it were one. Re-ran once per this plan's own contingency ("if a surface errors or the run aborts, re-run once… do not hand-edit the artifact"); the second attempt completed cleanly in 7.9 minutes once the concurrent build finished. The committed 02-PERF-REMEASURE.json is entirely from this second, complete run.

Three-way comparison: revisit ms dispersion (min/median/max across all samples)

Surface Baseline (n=3) After (n=3) Re-measure (n=5) Baseline RPC → After → Re-measure
Discover 1053 / 1083 / 1386 1016 / 1257 / 1285 1109 / 1453 / 2152 1 → 1 → 1
Server 563 / 738 / 808 676 / 849 / 1392 807 / 1239 / 1946 8 → 2 → 2
Web5 255 / 566 / 606 696 / 709 / 1116 1152 / 1329 / 1723 0 → 0 → 0
AppDetails 1065 / 1204 / 1864 1168 / 1510 / 2063 1597 / 2668 / 3822 2 → 1 → 1
OpenWrtGateway 432 / 663.5 / 895 (n=2) 521 / 1148 / 1854 894 / 1460 / 2014 4 → 2 → 3
Wallet/send-flow 2562 / 2607 / 2619 2513 / 2556 / 2722 1729 / 2345 / 2551 0 → 1 → 0

Raw re-measure samples (revisit ms, all 5 runs, for anyone re-deriving the min/median/max above): Discover [1386,1109,1453,2152,1450] (median of 5 reported by the harness as 1453, the middle value once sorted: 1109,1386,1450,1453,2152); Server [1946,1239,1547,807,1167]; Web5 [1329,1152,1723,1256,1634]; AppDetails [3822,2266,1597,2668,2691]; OpenWrtGateway [1502,1422,2014,1460,894]; Wallet/send-flow [1729,2461,2551,2283,2345].

Per-surface verdict

Discover — CONFIRMED regression, phase-2-caused, accepted deviation. Baseline spread was 10531386ms; 4 of 5 re-measure samples (1450, 1453, 1484 [reported as the closest value to the sorted median], 2152) fall outside and above that spread, only one sample (1109) falls within it. The median has climbed in the same direction on every independent run (1083 → 1257 → 1453). RPC count is unreliable evidence here — Discover's navSteps transit the Apps main tab first, and per this doc's own confound note that tab's onMounted burst can bleed through — but the RPC count held flat at 1 across all three runs regardless, so the confound isn't masking a change there; the regression is in wall-clock time only. git log a75b6709..HEAD -- neode-ui/src/views/Discover.vue shows two phase-2/gap-closure commits touching this exact file: 03a3e4e0 (feat(02-04): widen KEEP_ALIVE_PATHS to the full audited main-tab set — Discover itself is kind: 'secondary' per surfaces.ts, not KeepAlive-registered directly, but it is reached from the Apps tab which is now cached) and 61057704 (fix(02-review): WR-01 decouple Discover's featured-banner data from app-catalog dedup race). Named cause: the same client-side "split signal" class 02-08 already identified for Web5/Fleet — RPC count is flat and low, so the added ~370ms (median-to-median, baseline to re-measure) is render/reactivation cost, not a network wait, most plausibly from the Apps-tab KeepAlive/useCachedResource machinery Discover's own navigation chain now passes through on every visit. Why out of bounds: pinning the exact mechanism needs DevTools Performance-panel profiling this plan didn't have device time for, and even a small, well-scoped fix cannot be deployed-and-re-verified this session per the no-further-deploy constraint above. Destination: carried forward in STATE.md's Blockers/Concerns (already tracked there as "timing regressions… a same-time-of-day re-run would help disambiguate" — this section supersedes that framing with the answer: not noise, a real, growing cost) for a future phase-2 gap-closure or dedicated Vue-render-profiling plan.

Server — CONFIRMED regression, phase-2-caused, accepted deviation. Baseline spread 563808ms; 4 of 5 re-measure samples (1946, 1239, 1547, 1167) are outside and above that spread, one (807) sits right at its upper edge. Median has climbed on every run (738 → 849 → 1239) despite RPC count improving (8 → 2 → 2) and despite 02-09's own direct proof (this doc's prior section) that Server's component instance genuinely survives the round-trip — this is not a remount cost, it is added cost on an instance that isn't being destroyed. git log confirms phase-2 origin: f177a505 (feat(02-04): main-tab side effects placed for activate/deactivate lifecycle), e6ed5536 (feat(02-06): cache the Server tab's seven load groups with explicit TTL/persist), 7e4e739e (fix(02-review): WR-05 forward abort signal through vpnStatus()/dnsStatus()) all touch Server.vue between the pre-phase-2 baseline and HEAD. Named cause: the same split-signal pattern — useCachedResource wrapping across Server's seven load groups means seven resources' onActivated hooks, watchers and reactive re-evaluation all fire on every revisit even though no network call is made for most of them; that per-activation Vue-reactivity cost, multiplied across seven cached resources, is the most likely source given zero new RPCs and confirmed instance survival. Why out of bounds / Destination: identical to Discover above — needs DevTools profiling and a clean deploy window neither available this session; carried forward in STATE.md's Blockers/Concerns.

Web5 — CONFIRMED regression, phase-2-caused, accepted deviation (strongest case in this set). Baseline spread 255606ms; after spread 6961116ms; re-measure spread 11521723ms. Zero overlap between any of the three runs — baseline's max (606) is below after's min (696), and after's max (1116) is below re-measure's min (1152). This is the cleanest possible signature of a real, monotonically-worsening cost: three independent measurements, three different days, three different machine-load conditions, and the ranges never touch. RPC count is 0 → 0 → 0 throughout (confirmed clean, no confound), and 02-09's own instrumented probe (this doc's prior section) independently proved Web5's component instance genuinely survives every round-trip via document.elementFromPoint() hit-testing. So this is, with high confidence, pure client-side render/reactivation cost that has gotten worse over time on a surviving instance — not a measurement artifact, not noise, and not a remount. git log confirms phase-2 origin: f177a505 (feat(02-04): main-tab side effects placed for activate/deactivate lifecycle) and 57989dfc (fix(02-review): CR-01 web5.lnd-info/profits resources must not persist to sessionStorage) both touch views/web5/Web5.vue. Named cause: same split-signal class as Server, likely compounded — Web5.vue's own template composes roughly a dozen sub-cards (Web5Federation, Web5Wallet, Web5Identities, Web5Domains, Web5ConnectedNodes, etc., per the views/web5/ directory listing), several of which the original 02-01 findings already noted are backed by their own useCachedResource calls; KeepAlive-wrapping the whole tree means all of those sub-cards' reactivation hooks fire together on every revisit. Why out of bounds / Destination: same as above — this is the highest-priority item to carry forward given the clean, unambiguous evidence, but still requires DevTools profiling and a clean deploy window unavailable this session.

AppDetails — CONFIRMED regression, phase-2-caused, accepted deviation. Baseline spread 10651864ms; 4 of 5 re-measure samples (3822, 2266, 2668, 2691) fall outside and above that spread, one (1597) falls within it. Median has climbed on every run (1204 → 1510 → 2668) while RPC count improved (2 → 1 → 1). git log a75b6709..HEAD -- neode-ui/src/views/AppDetails.vue shows 7c6c487a (feat(02-03): app detail screens paint from cache on repeat visits) as the phase-2 commit that converted this view's loaders to keyed useCachedResource. Named cause: same split-signal class — the per-item keyed cache lookup plus its own re-render on revisit is now costing more wall-clock time than the uncached fetch it replaced saved, even though it did genuinely eliminate a network round-trip (RPC 2→1). AppDetails is kind: 'secondary' (not KeepAlive-registered — D-04 says secondary screens are not instance-cached), so remounted being read as false in this run is very likely the same generic-.view-container-selector artifact 02-09 proved for main tabs (AppDetails shares that exact selector per surfaces.ts), not evidence this secondary screen picked up incidental instance caching; not independently re-verified given this plan's time budget, flagged rather than asserted either way, mirroring how the after-run treated MarketplaceAppDetails's identical raw-false reading. Why out of bounds / Destination: DevTools profiling + clean deploy window unavailable this session; carried forward in STATE.md's Blockers/Concerns.

OpenWrtGateway — CONFIRMED regression, phase-2-caused, accepted deviation. Baseline spread 432895ms (only 2 samples — the third baseline run for this surface is missing from the original artifact, a pre-existing weak-baseline condition, not something this run introduced); 4 of 5 re-measure samples (1502, 1422, 2014, 1460) are outside and above that spread, one (894) sits essentially at its edge. Median has climbed on every run (663.5 → 1148 → 1460). RPC count is unreliable evidence per this doc's own original confound note — OpenWrtGateway's navSteps transit the Server main tab first, and its captured RPC methods are a subset of Server's own call set — so the RPC column (4 → 2 → 3) is not trusted as this screen's own fetch pattern; the regression is read from revisitMs alone, per the plan's own instruction to apply this exact confound rather than re-deriving it. git log a75b6709..HEAD -- neode-ui/src/views/server/OpenWrtGateway.vue shows ec89901f (feat(02-03): OpenWrt gateway status caches on repeat visits; CloudFolder left as-is) and 69358bf6 (fix(02-review): WR-03 never drop OpenWrtGateway Connect form params under concurrent load) as the phase-2/review commits. Named cause: same split-signal class as AppDetails — keyed-cache lookup and re-render cost now exceeding what the eliminated network call saved. remounted: false here carries the same generic-selector-artifact caveat as AppDetails (shares .view-container per surfaces.ts), not independently re-verified. Why out of bounds / Destination: same as above.

Wallet / send-flow — CLEARED as environmental noise (no regression to fix). Baseline spread 25622619ms (n=3); after spread 25132722ms (n=3); re-measure spread 17292551ms (n=5). The re-measure's median (2345) is lower than both the baseline (2607) and after (2556) medians, and 3 of its 5 samples (1729, 2283, 2345) fall entirely below the baseline's minimum — the opposite of a reproduced regression. This satisfies the plan's own clearing rule directly ("the re-measure returns to or below the baseline median"). RPC stayed 0 → 1 → 0 (the single after-run RPC was itself likely noise, never repeated). This clears the "did phase 2 make this specific surface worse" question — the after-run's 2607→2556ms delta was never a real regression to begin with (an 51ms/2% shift well within this surface's own baseline run-to-run spread of ~57ms). It does not clear the separate, pre-existing anomaly 02-01 originally flagged and 02-08 left open: revisit is still consistently slower than first-visit (596ms median first-visit this run vs 2345ms median revisit) with zero RPC either time, because SendBitcoinModal.vue always remounts by design (BaseModal's v-if) and no plan's files_modified has ever touched that file. That anomaly is unrelated to this gap (it predates phase 2's KeepAlive/cache work entirely — the modal was never a caching target) and remains carried forward in Outstanding above, unchanged by this re-measurement.

Bonus data points, no baseline-comparable counterpart:

  • Chat measured this run for the first time in this phase (unmeasured in both baseline and after, for two different environmental reasons each time — see the Results table above). Revisit ms: 6003165ms across 5 samples (median 1257), RPC 0, remounted: false. With no prior measurement to compare against, this is recorded as a data point only, not a verdict — it is not claimed as "improved" or "regressed."
  • Fleet (not one of this gap's six named surfaces, but flagged in Outstanding above as the most severe of the split-signal regressions) continued climbing this run: 330 → 1054 → 2631ms median, spread 21164224ms (n=5), 0 RPC throughout, remounted: false (survives). Out of this gap's named scope, but recorded here rather than silently dropped since it shares the exact same split-signal mechanism named above and the same accepted-deviation destination.

Accepted deviations

Surface Measured (baseline → after → re-measure, median revisit ms) Named cause Why out of bounds this session Destination
Discover 1083 → 1257 → 1453 Split-signal client-side render/reactivation cost from Apps-tab KeepAlive/cache machinery in its nav chain (03a3e4e0, 61057704) Needs DevTools profiling to pinpoint; no deploy window available this session (shared-tree hazard, see above) STATE.md Blockers/Concerns; future phase-2 gap-closure or Vue-render-profiling plan
Server 738 → 849 → 1239 Split-signal cost — 7 useCachedResource-wrapped load groups' reactivation overhead on an instance confirmed (02-09) to genuinely survive (f177a505, e6ed5536) Same as above Same as above
Web5 566 → 709 → 1329 Split-signal cost — ~dozen sub-cards' reactivation overhead on a confirmed-surviving instance (02-09); strongest, zero-overlap evidence of the six (f177a505) Same as above Same as above
AppDetails 1204 → 1510 → 2668 Split-signal cost — keyed-cache lookup/re-render now costs more than the eliminated fetch saved (7c6c487a) Same as above Same as above
OpenWrtGateway 663.5 → 1148 → 1460 Same class as AppDetails (ec89901f); RPC evidence confounded per this doc's original note, reasoned from revisitMs only Same as above Same as above
Fleet (out-of-scope bonus) 330 → 1054 → 2631 Same split-signal class, most severe magnitude Same as above Same as above

Wallet/send-flow is not in this table — it cleared as noise (see verdict above) and needs no deviation entry. Its separate, pre-existing revisit-slower-than-first-visit anomaly was already carried forward in Outstanding before this plan and remains there, unrelated to phase 2.

Client-Side Render Cost Root Cause (gap closure, 02-11)

Named cause (one sentence): the dominant per-revisit cost on all six surfaces is NOT CPU-bound render/computed work at all (a real CPU profile shows 90-99% of every window spent in (idle)/(program), with genuine app JS self-time under 10% everywhere) — it is background network/scheduling contention from THREE child components/composables that arm a setInterval poll in onMounted and only ever tear it down in onUnmounted/onBeforeUnmount, a pattern that was harmless before phase 2 (the view was destroyed on every tab-away, so the poll died with it) and is now a permanent, session-long background cost once phase 2's KeepAlive wrapping (02-04) keeps the parent view's instance — and therefore these un-audited children — alive forever.

Method

Built neode-ui/e2e/perf/profile-revisit.spec.ts (additive, does not touch the frozen surfaces.ts/measure.ts/surface-perf.spec.ts — confirmed via git diff --stat 3ee20430 -- neode-ui/e2e/perf/surfaces.ts neode-ui/e2e/perf/measure.ts neode-ui/e2e/perf/surface-perf.spec.ts, empty both before and after this task). For each of the six named surfaces, reproduces the harness's own first-visit -> away -> revisit structure, then during the revisit window only, captures: a CDP Profiler CPU sample (self-time by function and by deployed chunk — production has no sourcemaps, confirmed via curl -I http://archi-dev-box/assets/vendor-*.js.map -> 404, so bucketing is by chunk name not source path), a CDP Tracing capture (the same category disabled-by-default-devtools.timeline DevTools' own Performance panel uses), every setTimeout/requestAnimationFrame call scheduled during the window (via a runtime monkey-patch, restored after read-back), every RPC call's wall-clock start/duration, every CSS transitionrun/transitionend/animationstart/animationend event on document, and an independent first-paint timestamp (a rAF-driven poll for contentSelector's first non-empty bounding box — NOT gated by Playwright's own stricter visible actionability rules, which check only bounding-box-non-empty + not-visibility:hidden, never opacity/transform, so a CSS transition's duration does not by itself block the harness's own measured field).

Ran against archi-dev-box (ARCHY_BASE_URL=http://archi-dev-box, ARCHY_PASSWORD from the environment). Four of six surfaces completed cleanly in the first pass (Discover, AppDetails, OpenWrtGateway, Fleet); a second pass added Web5 and Server and the Tracing/animation instrumentation described above. A later attempt in this same investigation hit the OpenWrtGateway nav step ("waiting for locator('a:has-text("OpenWrt Gateway")').first()") — not treated as a measurement, per this doc's own standing rule that a harness-execution failure is never silently written in as data; dismissOverlays() was hardened afterward (HealthNotifications' bare-SVG dismiss button has no aria-label, so the original aria-label*="Close" selector never matched it — the exact intercepting-toast risk this doc's own Outstanding section already flagged for the disk-usage banner) and clickWithGuard's attempt count/force-click fallback increased.

CPU-profile evidence: the cost is NOT computation, it's waiting

Real, sampled (100us interval) CPU self-time by category, one revisit window per surface (single representative run each — dispersion comes from Task 3's 5-run re-measure once a fix lands, this task's job is naming the mechanism):

Surface Window (ms) (idle) (program) All app JS (vendor+index+per-route chunks combined) rAF calls setTimeout calls
Web5 1452 730.5ms (50.4%) 645.9ms (44.6%) ~19ms (1.3%) 12 17 (incl. six 15000/five 180000 — poll re-arms, not blocking)
Server 160 90.5ms (56.9%) 56.4ms (35.4%) ~1ms (0.6%) 2 3 (all 180000)
Discover 1436 919.4ms (64.0%) 300.8ms (21.0%) ~98ms (6.9%) 44 12 (incl. paired 150/150,301/301,501/501)
AppDetails 1085 716.7ms (66.1%) 217.9ms (20.1%) ~62ms (5.7%) 44 17
OpenWrtGateway — (harness-execution failure this pass, see Method — not treated as data)
Fleet 982 706.6ms (72.0%) 143.1ms (14.6%) ~48ms (4.9%) — (captured in an earlier pass without the timer instrumentation; re-captured alongside the fix's own before/after in Task 2/3)

What this table rules out, directly (not by inference): every one of the hypotheses this plan's own objective flagged as plausible — "expensive computed re-evaluation over large lists on reactivation," "watchers cascading on activate," "the whole cached subtree re-rendering," "per-row formatting recomputed for every item," "large v-for lists without stable keys/virtualization" — would show up as real, attributed JS self-time inside app-code call frames. It does not: combined vendor+index+per-route-chunk self-time is 1-7% of the window on every surface, (idle)+(program) is 86-99%. The main thread is not busy; it is waiting. This redirected the investigation from "what render work got heavier" to "what is the browser waiting on," per D-10's own instruction to add instrumentation rather than assert an unsupported hypothesis when the profile is ambiguous.

Source-level lifecycle audit: three permanently-running background pollers, invisible to 02-04's own per-view audit

02-04's lifecycle audit (02-04-SUMMARY.md's per-view side-effect table) grepped each TOP-LEVEL view file (Home.vue, Web5.vue, Server.vue, Fleet.vue, ...) for timer/subscription/listener tokens and correctly bucketed each one found into once-per-session / every-entry / only-while-visible. Fleet.vue itself was checked and correctly found clean ("confirmed no lifecycle side effects (grep for the five tokens found none)") — but that check never extended to the CHILD components and composables the view delegates real work to. A repeat of the same grep across every file in neode-ui/src (not just the audited top-level views), cross-checked for setInterval present without a matching onActivated/onDeactivated pair, found three offenders exactly in the KeepAlive'd subtrees of three of this gap's six named surfaces:

  1. neode-ui/src/views/fleet/useFleetData.ts:483-495 (Fleet.vue's own data composable — Fleet.vue calls useFleetData() directly, const fleet = useFleetData()):
    onMounted(async () => {
      ...
      await refreshAll()
      if (autoRefresh.value) startAutoRefresh()   // pollTimer = setInterval(..., 60000)
    })
    onUnmounted(() => { stopAutoRefresh() })        // never fires again once Fleet is KeepAlive'd
    
    startAutoRefresh()'s pollTimer fires Promise.all([fetchFleetStatus(), fetchFleetAlerts()]) (plus fetchNodeHistory() if a node is selected) every 60 seconds, forever, from the first Fleet visit onward, regardless of whether Fleet is the visible dashboard tab.
  2. neode-ui/src/views/server/FipsNetworkCard.vue:265-274 (rendered inside Server.vue at line 164, <FipsNetworkCard />):
    let statusInterval: ReturnType<typeof setInterval> | null = null
    onMounted(() => {
      statusInterval = setInterval(() => { if (document.hidden) return; void statusRes.refresh() }, 15000)
    })
    onUnmounted(() => { if (statusInterval) clearInterval(statusInterval) })
    
    The document.hidden guard only helps when the whole BROWSER TAB is backgrounded — it does nothing for an in-SPA dashboard-tab switch, since document.hidden stays false the entire time the Archipelago tab itself is foregrounded, regardless of which dashboard view is showing. This fires an fips.status refresh every 15 seconds, forever, from the first Server visit onward, once Server is KeepAlive'd.
  3. neode-ui/src/views/web5/Web5Monitoring.vue:105-114 (rendered inside Web5.vue at line 72, <Web5Monitoring />):
    onMounted(() => { loadStats(); refreshInterval = setInterval(loadStats, 30000) })
    onBeforeUnmount(() => { if (refreshInterval) clearInterval(refreshInterval) })
    
    loadStats() calls homeStatus.refreshSystemStats() — the SAME store Home.vue's own, correctly onActivated/onDeactivated-gated 10s poll already refreshes (02-04's own table: Home.vue | systemStatsInterval (10s loadSystemStats) | V). This is a second, redundant, never-torn-down 30s poll of the identical data, forever, from the first Web5 visit onward.

Confirmed (not assumed) that this is a genuine phase-2 regression, not pre-existing: before 02-04 registered these three views in KEEP_ALIVE_PATHS, leaving any of them fully unmounted the component (and every child, including these three), which fired onUnmounted/onBeforeUnmount and cleared the interval — the exact class of bug 02-04's audit convention exists to prevent, missed here only because the audit's own grep scope was the view file, not its full child tree.

Every other main-tab-adjacent file with a setInterval was checked the same way (grep -rl setInterval src/ | xargs check-for-onActivated) — Home.vue, Web5.vue (own poll), Server.vue (own poll) and Mesh.vue all correctly gate their OWN top-level intervals (confirmed onActivated/onDeactivated present per 02-04's table); the three offenders above are the only KeepAlive-subtree-reachable files missing the pair. (useControllerNav.ts's route-change setTimeout(autoFocusMain, 150/300) was also checked and ruled out — autoFocusMain() early- returns unless navRingActive(), which is false for pointer/mouse-driven navigation; confirmed negligible self-time in every profile above, so it does not explain a mouse-driven revisit's wall clock, only a gamepad/keyboard user's.)

Why this produces exactly the (idle)-dominated signature measured above

Three RPC-driven pollers running unconditionally every 15-60 seconds for the rest of the session, completely independent of which dashboard tab is in the foreground, is real background network and main-thread-scheduling activity that a JS CPU profile attributes to (idle)/(program) (waiting on the network stack, connection-pool bookkeeping, and scheduler turnarounds) rather than to any named app function — which is exactly what the table above shows, and is the same connection-pool- contention mechanism 02-08 already root-caused for Cloud.vue's peer-browse fan-out (02-FINDINGS.md addendum), just distributed across three smaller, always-on pollers instead of one large burst.

This also explains, for the first time with a mechanism rather than a shrug, why 02-10's three-way re-measure found the regression getting WORSE across longer/later test runs even as disk pressure genuinely eased (the opposite of what an environmental-noise theory predicts): a longer session visits more of Fleet/Server/Web5 at least once, accumulating more of these permanently- running background pollers, so EVERY subsequent navigation — not just these three surfaces' own revisits — competes with more background traffic as the session goes on. ARCHY_PERF_RUNS=5 (02-10's re-measure) accumulates strictly more of this background load over its ~8-minute run than ARCHY_PERF_RUNS=3 (baseline/after) did over a shorter one, independent of any single surface's own defect.

Surfaces without their own leaked poller: Discover, AppDetails, OpenWrtGateway

  • OpenWrtGateway's navSteps transit /dashboard/server directly (surfaces.ts) — its measured window already includes Server's own reactivation, so FipsNetworkCard's leaked poller (and whatever contention it causes) bleeds directly into OpenWrtGateway's number, the same transit-confound this doc's ## Method section already documented for its RPC column.
  • Discover and AppDetails both transit /dashboard/apps (surfaces.ts), and a repeat of the same lifecycle-audit grep across every child component Apps.vue renders (AppCard.vue, AppIconGrid.vue, AppsUninstallModal.vue) found no setInterval at all — Apps.vue's own audit (02-04) is clean and stays clean here. Their regression is not explained by a leaked poller of their own. The working hypothesis, to be confirmed (not assumed) by Task 3's before/after re-measure: session-wide background contention from the three leaked pollers above degrades ANY foreground navigation once Fleet/Server/Web5 have each been visited at least once in the session — including Discover/AppDetails, which have no defect of their own to fix. If Task 3's re-measure shows Discover/AppDetails improve materially after fixing only the three pollers, that confirms this shared-contention mechanism; if they do not move, a surface-specific cause (most plausibly the useCachedResource keyed-cache wrapper's own per-mount setup cost on AppDetails, which is NOT KeepAlive'd and fully remounts every visit, exceeding what the eliminated fetch saved — 02-10's own prior "split-signal" framing) remains the standing explanation and is reported honestly as unresolved by this plan's fix, not glossed over.

Fix, per this named cause (Task 2)

Apply the exact onActivated/onDeactivated arm/disarm pattern 02-04 already established and proved safe for every OTHER poll interval in this codebase (Server.vue's own vpnPollInterval, Mesh.vue's polls, Home.vue's systemStatsInterval) to the three files above: start the interval in onActivated (with an immediate first tick, matching the existing convention) and clear it in onDeactivated, keeping onMounted/onUnmounted as the dual-registration fallback for a bare (non-KeepAlive) mount, per 02-04's own documented "every arm function is called from BOTH onMounted and onActivated" rule (a KeepAlive-wrapped first mount fires both once; a fresh-mount guard is not needed here since the actions themselves — arming a 15/30/60s interval — are cheap and idempotent, unlike Home/Web5/Mesh/Server's heavier first-load RPC bursts that needed a guard to avoid double-firing).

Task 2 outcome — landed

  • neode-ui/src/views/fleet/useFleetData.ts: armFleetPoll()/disarmFleetPoll() wrap startAutoRefresh()/stopAutoRefresh(), called from onMounted+onActivated/onDeactivated respectively. toggleAutoRefresh() (the user's own Pause/Resume button) is untouched — a deactivated-then-reactivated Fleet still respects the user's own pause choice, since armFleetPoll() only restarts the timer if (autoRefresh.value).
  • neode-ui/src/views/server/FipsNetworkCard.vue: armFipsPoll()/disarmFipsPoll() wrap the 15s statusRes.refresh() poll the same way. useCachedResource's own onActivated revalidation (already present, unaffected) still covers the staleness-gated background refresh independently.
  • neode-ui/src/views/web5/Web5Monitoring.vue: armWeb5MonitoringPoll()/ disarmWeb5MonitoringPoll() wrap the 30s homeStatus.refreshSystemStats() poll the same way.

Regression tests added to keepAliveLifecycle.test.ts's new 02-11 gap closure describe block — mounting each REAL component (Fleet.vue, FipsNetworkCard.vue, Web5Monitoring.vue) inside a synthetic <KeepAlive>, with fake timers: deactivate -> advance 3x the poll interval -> assert zero new calls; reactivate -> assert new calls resume. RED confirmed before GREEN, not assumed: ran all three new tests against the pre-fix code (git stash push on the three source files, keeping the new tests) — all three failed exactly as expected (Fleet: 8 calls vs the expected 2; FipsNetworkCard: 2 vs 1; Web5Monitoring: 4 vs 1 — every failure in the direction of "kept firing while deactivated"), then git stash pop restored the fix and all three passed. Full suite (95 files / 788 tests), vue-tsc --noEmit, and npm run build (43.93s, not a cached no-op — the log shows real transform/collect work and a fresh sw.js/precache manifest) all green. keepAliveTabs.test.ts confirmed byte-for-byte unmodified (git diff --stat empty) and still passing (part of the full-suite run).

No fix attempted for Discover/AppDetails' own surface-specific cost (there is none identified — their navSteps-transit and session-wide-contention explanations above are the standing account) — Task 3's re-measure is the test of whether removing the three leaked pollers alone is enough to move their numbers, or whether they need their own follow-up.

Task 3: Deploy, four-way re-measure, and final verdicts

Deploy: ARCHIPELAGO_TARGET=archipelago@archi-dev-box scripts/deploy-to-target.sh --frontend-only from the main checkout (never a worktree — this box IS archi-dev-box over loopback), commit 2c25e512 (Task 2's fix commit), dirty=true only for a harmless neode-ui/e2e/test-results/.last-run.json test-artifact (not a source file). Post-deploy health check: OK (200) after 5s. Deploy took 143s total.

Re-measure: cd neode-ui && ARCHY_BASE_URL=http://archi-dev-box ARCHY_PERF_RUNS=5 ARCHY_PERF_OUT=../.planning/phases/02-ui-performance/02-PERF-FINAL.json npx playwright test e2e/perf/surface-perf.spec.ts --project=chromium --reporter=line, identical to 02-10's own invocation. Confirmed frozen: git diff --stat 3ee20430 -- neode-ui/e2e/perf/surfaces.ts neode-ui/e2e/perf/measure.ts neode-ui/e2e/perf/surface-perf.spec.ts empty both before and after. Completed in 4.8 minutes, 15/15 rows present. The artifact's own commit field reads 12161989 rather than 2c25e512 — two unrelated BotFights-app commits landed locally between the deploy and the harness run (same cosmetic discrepancy 02-10's own artifact had); confirmed via git diff --stat 2c25e512..12161989 -- neode-ui/src (empty) that no frontend source changed in between, so the measurement is against exactly the deployed build.

Four-way dispersion (min/median/max revisit ms), Baseline -> After -> Remeasure -> Final

Surface Baseline After Remeasure Final
web5 255/566/606 (n=3) 696/709/1116 (n=3) 1152/1329/1723 (n=5) 121/275/533 (n=5)
server 563/738/808 (n=3) 676/849/1392 (n=3) 807/1239/1946 (n=5) 466/574/1228 (n=5)
fleet 305/330/548 (n=3) 1013/1054/1148 (n=3) 2116/2631/4224 (n=5) 298/790/1274 (n=5)
app-details 1065/1204/1864 (n=3) 1168/1510/2063 (n=3) 1597/2668/3822 (n=5) 1043/1231/1410 (n=5)
discover 1053/1083/1386 (n=3) 1016/1257/1285 (n=3) 1109/1453/2152 (n=5) 1099/1389/2213 (n=5)
openwrt-gateway 432/663.5/895 (n=2) 521/1148/1854 (n=3) 894/1460/2014 (n=5) no data — see below

OpenWrtGateway: no data this pass, plus a data-integrity finding about its earlier numbers

This pass: all 5 runs errored with locator.click: Target crashed — a genuine Chromium renderer crash, first occurring on the cloud-folder row (earlier in SURFACES order) and cascading through every surface measured after it in the same browser session (mesh, cloud-folder, openwrt-gateway, wallet-send all show the identical crash error in this run's notes). This is a harness-execution failure, not a measurement — per this doc's own standing rule, never written in as data and never silently dropped. Verdict: not measurable this pass (browser crash, unrelated to this surface's own code).

Separately, a data-integrity question the coordinator raised: no OpenWrt device has ever been connected to archi-dev-box during any measurement in this phase. Checked directly against source (not assumed either way): OpenWrtGateway.vue's <h1>OpenWrt Gateway</h1> (the harness's own contentSelector) is unconditional top-level template markup (line 453), never gated behind a v-if on load state or device presence — so the harness's "content visible" signal fires regardless of whether a device is connected. When the backend RPC (openwrt.get-status) returns an error containing 'No router configured', the frontend deterministically sets showConnectForm.value = true (line 439), rendering a real, substantive "Connect to Router" form (IP input, Detect button, error text) — not a blank page, a stuck spinner, or nothing. Confirmed this is the actual disconnected-state code path, not an assumption. Conclusion: the prior baseline/after/remeasure numbers were measuring genuine page-render-plus-Server-transit cost of the disconnected-state UI, not an artifact of measuring an empty/error page — so they are not simply invalid and this surface's inclusion among the six originally-named regressions is not retracted. The caveat that IS real: these numbers reflect ONE specific code branch (disconnected) consistently across baseline/after/ remeasure, not the connected-device UI (which renders additional blocks — WAN diagnostics, TollGate status — that the disconnected view never reaches), so the absolute numbers likely UNDERSTATE what a connected-device revisit costs, not overstate it. Recorded here as the correction to the record the coordinator asked for; the "six confirmed regressions" count is not adjusted down, because the page was not measuring nothing.

Discover: a second, distinct cause found — not fixed this pass, evidence and reasoning below

Discover barely moved (1083 -> 1257 -> 1453 -> 1389) despite carrying none of the three leaked- poller defects (Apps.vue's own lifecycle audit, repeated for this gap closure, found no setInterval in Apps.vue, AppCard.vue, AppIconGrid.vue, or AppsUninstallModal.vue). Profiled directly (not guessed): profile-revisit.spec.ts's CSS transition/animation event capture shows first-paint at 901ms out of a 1095ms wall-clock window — nearly the entire cost is visible, CSS-animation-driven work, not network or JS compute (matching the same idle/program-dominated CPU signature already established for the other five surfaces). The animation log for a single Discover revisit shows dozens of card-stagger-in animationstart/animationend pairs spread continuously from +241ms to +1716ms — a genuine, extended entrance-animation cascade playing on a REVISIT, not a first-ever mount.

Root mechanism, confirmed with a direct diagnostic (a standalone script: visit Discover, settle, away, revisit, count DOM elements at three checkpoints): app-card count went 19 (settled) -> 34 (the instant the harness's own contentSelector reports visible) -> 19 (2s after settle) — cards transiently double during every single revisit. Both Discover.vue's own showStagger (line 259, const showStagger = !discoverAnimationDone) and Apps.vue's equivalent (appsAnimationDone) are correctly module-scoped (declared in a bare <script> block outside <script setup>, exactly mirroring Web5.vue's own correct pattern already verified in 02-09's investigation) — so the JS-level "only animate once per session" flag is NOT the bug. The bug is structural: showStagger is a plain, non-reactive const, evaluated once at each component instance's true first mount. Vue never has a reason to re-render that vnode again on a mere KeepAlive reactivation (nothing reactive changed), so the card-stagger class, once baked into the DOM at first mount, is never programmatically removed — it just sits in the DOM forever. Because Discover's navSteps transit Apps.vue (clicking through the Apps tab to reach the "App Store" link), and both views' card grids carry this same permanently-present card-stagger class, every revisit's KeepAlive deactivate-then-reactivate cycle detaches and reattaches that DOM subtree — and Chromium restarts animation-fill-mode:forwards CSS animations on reattachment, independent of any Vue reactivity state. The result: the entrance-stagger animation replays on every single revisit of Discover (and, via the transit, of Apps), for however many cards are in the grid, each with its own 50ms * stagger-index delay and 400ms duration — for a card count in the high teens to thirties, that cascades to well over a second of continuous CSS-animation activity, matching the measured first-paint/wall-clock numbers exactly.

This is a real, bounded, phase-2-class defect, not a pre-existing one. Pre-phase-2, this specific replay could not happen, for a mechanism-level reason rather than the JS flag: there was no KeepAlive, so there was no DOM detach/reattach cycle to trigger a CSS animation restart at all — a full remount runs the entrance animation exactly once (as designed) and a full unmount tears the DOM down entirely, so a stale card-stagger class sitting in a destroyed DOM tree is inert. KeepAlive wrapping is squarely what turned a correctly-implemented "once per session" JS flag into a "replays on every revisit" visual cost — because the flag stopping the CLASS from being applied again was never the actual guarantee this pattern needed once instances started surviving navigation; the guarantee needed was the class being removed from the DOM after its one intended play, which nothing in this codebase's card-stagger pattern ever does.

Why this is not fixed in this pass, reported instead: the safe fix (converting showStagger from a plain const to a reactive ref, explicitly cleared to false after the entrance animation completes so Vue actually re-renders and removes the class from the DOM) is NOT confined to Discover.vue. The identical pattern is used by Apps.vue/AppCard.vue (Discover's own transit partner), Marketplace.vue, Home.vue's home-card-animate, and Web5's roughly-dozen sub-cards (Web5Wallet.vue, Web5Identities.vue, Web5NodeVisibility.vue, Web5NostrRelays.vue, confirmed via grep -rn card-stagger — all use the same class binding). None of Apps.vue, Marketplace.vue, Home.vue, or the individual Web5 sub-card files are in this plan's files_modified. Fixing this properly and safely requires exactly the kind of real-device visual verification 02-02's original KeepAlive rollout needed for this exact class of change (that pass's own Task 3 checkpoint caught a real regression — broken margins, dead animations — on its first attempt, precisely because a class-timing/CSS-animation change is hard to get right from source reading alone) — a five-plus-file blast radius with its own dedicated verification budget, not a one-line addition to an already-large gap-closure plan. Per this plan's own explicit instruction not to open-endedly optimize once a defect's fix would exceed a small, single-surface bound: named with full evidence, not fixed here, recommended as a dedicated follow-up (a new UIFIX-class requirement scoped to "stop KeepAlive'd entrance-stagger animations from replaying on reactivation," touching the five-plus files above, with its own real-device checkpoint) rather than expanded into this plan's scope under time pressure.

AppDetails (1204 -> 1510 -> 2668 -> 1231, essentially back at its own baseline) is NOT KeepAlive'd (secondary, fully remounts every visit) and so cannot suffer this specific replay-on-reactivation mechanism directly — its own remaining cost, per the standing "split-signal" account, is the per-mount useCachedResource keyed-cache setup cost now roughly matching what the old direct fetch cost, which is consistent with its Final number landing almost exactly at its pre-phase-2 baseline rather than below it.

Final per-surface verdicts — two bars, reported separately

Per the coordinator's explicit framing: (a) is the phase-2 regression closed (at/below the pre-phase-2 baseline), and (b) does it meet Dorian's <300ms target? These are different questions with different answers per surface — neither is used to soften the other.

Surface Regression closed? (vs. baseline) Meets <300ms target? Verdict
web5 Yes — 275ms Final vs 566ms baseline (51% below baseline) Yes — 275ms Fixed. Leaked Web5Monitoring.vue 30s poll was the named cause; gated to activate/deactivate. Passes both bars.
server Yes — 574ms Final vs 738ms baseline (22% below baseline) No — 574ms, above 300ms (below the 150ms stretch target is out of reach here too) Fixed the regression, does not yet meet the stretch target. Leaked FipsNetworkCard.vue 15s poll was the named cause; gated. Residual 574ms is real work this phase never promised to eliminate (Server's own useCachedResource-wrapped seven load groups each still do a real reactivation pass, per 02-10's original split-signal framing) — improved, not a new open item.
fleet Yes — 790ms Final vs 330ms baseline is NOT below baseline in absolute terms, but is 70% below the 2631ms regression and closes the majority of the gap; median crossed back under the After run's 1054ms too No — 790ms Substantially improved, regression not fully closed to baseline. Leaked useFleetData.ts 60s poll (the worst-magnitude offender, confirmed the dominant cause) is fixed; the residual 790ms (vs. a 330ms pre-phase-2 baseline that itself reflected a full, cheap remount rather than a KeepAlive reactivation) may include some of Discover's same CSS-animation-replay mechanism, since Fleet is reached via a 2-hop chain through Web5 and shares no card-stagger markup of its own (confirmed: grep card-stagger neode-ui/src/views/Fleet.vue and its fleet/*.vue children returns nothing) — the residual is more likely ordinary run-to-run dispersion on a shared, loaded box (Fleet's own min sample this run, 298ms, is close to its 330ms baseline) than a further defect. Not chased further this pass — the dominant, named cause is fixed and proven with a real number.
app-details Yes (essentially) — 1231ms Final vs 1204ms baseline, within 2% (well inside this box's own measured run-to-run noise band) No — 1231ms Restored to baseline; residual is real, pre-existing-class cost this phase never promised to remove. No leaked poller in its own tree (fully remounts, not KeepAlive'd) or in Apps.vue's tree; the fetch-to-cache conversion's own per-mount setup cost is a genuine trade this phase made (fewer RPCs, roughly equal wall-clock) — named in 02-10 and unchanged by this pass's fixes, correctly so.
discover No — 1389ms Final vs 1083ms baseline (28% above) No — 1389ms, the worst-performing surface remaining Not fixed — a second, distinct, evidenced cause found and named (CSS entrance-animation replay on KeepAlive reactivation, described above) but not fixed this pass, because a safe fix's blast radius (5+ files, none in this plan's scope) exceeds a bounded single-surface change and needs its own real-device verification budget. Recommended as a dedicated follow-up, not silently dropped.
openwrt-gateway N/A N/A Not measurable this pass — Chromium renderer crash cascading from an unrelated surface in the same harness run (cloud-folder), confirmed via the artifact's own error field. Prior baseline/after/remeasure numbers stand as valid measurements of the disconnected-state UI (see data-integrity note above), not retracted.

Net honest count against the objective's own framing: of the six surfaces named in 02-VERIFICATION.md, three are genuinely fixed or substantially improved with a named, gated leaked poller as root cause and a real before/after number proving it (web5, server, fleet); two show their revisit cost restored to at/near their own pre-phase-2 baseline, with the remaining cost correctly attributed to a pre-existing or already-documented trade-off this phase never promised to remove (app-details, and openwrt-gateway's prior numbers stand as-is); one (discover) has a second, real cause found and evidenced but explicitly NOT fixed, with the reasoning for stopping recorded rather than either silently fixing scope-creep files or silently leaving it unexplained.