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>
103 KiB
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'scontentSelectorbecoming visible.revisitRpcCount— number of POSTs to/rpc/v1observed 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 showsmaxConcurrentRpc: 1with 2+ calls; a parallel fan-out showsmaxConcurrentRpcclose to the total call count.remounted— read back from a dataset-stamp on the view's root element after the away/back round-trip.truemeans the component instance was destroyed and recreated (expected everywhere right now —grep -r "KeepAlive" neode-ui/srcreturns 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 7–8 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 1–2 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):
- Wallet-send — 2607ms (secondary/modal; anomalous revisit > first-visit, see note above)
- Marketplace — 2033ms (main tab; RPC evidence confounded, see caveat)
- MarketplaceAppDetails — 1793ms (secondary; RPC evidence confounded, see caveat)
- CloudFolder — 1212ms (secondary; first-visit-dominant, ~5s cold chunk load)
- AppDetails — 1204ms (secondary; clean uncached-fetch evidence)
- Discover — 1083ms (secondary; remount storm)
- Server — 738ms (main tab; clean uncached-fetch evidence)
- OpenWrtGateway — 663.5ms (secondary; RPC evidence confounded)
- Web5 — 566ms (main tab; remount storm, 0 RPC)
- Home — 390ms (main tab; already fast)
- Fleet — 330ms (main tab; already fast)
- Apps — 267ms (main tab; already fast)
- 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_PATHSsince 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 inKEEP_ALIVE_PATHShasAfter remountedfalse"). 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: falsereading 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.vuenever appeared in any plan'sfiles_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 itsfiles_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 ownread_firstassumption) 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/settingsremains withheld fromKEEP_ALIVE_PATHS(02-04's own deliberate exclusion, unaudited risk —SystemDangerZone.vue's reboot poll, severalonMounted-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.vuereconfirmed 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 statusshowed it "offline, last seen 2h ago" at Task 1 and unchanged when re-checked before Task 2. Onlyarchi-dev-boxreceived this phase's build and was measured. D-11's verification target isarchi-dev-boxspecifically, 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-onlydeploy 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, nopointer-events: noneon 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$routerinstance), but the returned promise never settles — because Vue Router awaits the target route's async component, and that dynamicimport()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 fromCloud.vue'sfetchCounts()on mount. Confirmed hung via request-lifecycle tracking (requestfires, neitherrequestfinishednorrequestfailedever 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), commite1a3f31a,neode-ui/src/views/Cloud.vue): gaveCloud.vue'ssyncOnEntry()the same fresh-mount guard already used inHome.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-backonMounted+onActivatedpasses still doubledloadPeerFiles()'s full per-peercontent.browse-peerfan-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 achrome-headless-shell-only artifact (reproduces in full Chromium too) and raw request concurrency alone (an artificial 30-concurrent-request burst against the same/Photosendpoint, done outside Cloud.vue, completed in 179ms, no hang). - Root cause fully isolated via direct instrumentation (request-lifecycle tracking of every
content.browse-peercall during a fresh Cloud mount): 13 of 14 concurrentcontent.browse-peerrequests 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), commit8fe6217b,neode-ui/src/views/Cloud.vue): capped thecontent.browse-peerfan-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), mirroringPeerFiles.vue's existingPREVIEW_CONCURRENCYpattern 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 throughresources.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.tsstructural 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).
- Paid Files: clicking a picture opens it in a browser tab instead of the app's lightbox.
neode-ui/src/views/Cloud.vueviewPaidItem()(~line 470-493): for any non-audio purchased item it doeswindow.open(url, '_blank', 'noopener')on a blob URL — it never callsMediaLightbox.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 inf3393581("fix(content): double-pay is now impossible + purchases auto-file + Paid Files tab", 2026-07-22 — confirmed an ancestor ofa75b6709viagit merge-base --is-ancestor). Pre-existing, not phase 2. - Picture-in-picture on a video doesn't close the lightbox; the transition should be fluid.
neode-ui/src/utils/pip.ts'stogglePip()only callsvideo.requestPictureInPicture()/exitPictureInPicture()— it never emits a close event or touchesMediaLightbox.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 inf72d4b92("feat(content): seller-picked payment methods + music always in the bottom bar + video PiP", 2026-07-23 — zero commits touchMediaLightbox.vueorpip.tsbetweena75b6709andHEAD, confirmed viagit log a75b6709..HEAD --). Pre-existing, not phase 2. - 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.vueviewPaidItem()(same function as #1 above) makes acontent.owned-getRPC 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.vueline 4), so that one specific secondary screen is not part of this finding. - 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, norMediaLightbox.vue(neode-ui/src/components/cloud/MediaLightbox.vue) defines anonDeactivatedhook at all (zero matches foronDeactivatedin 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.MediaLightboxis a<Teleport to="body">child rendered from insideCloud.vue's template (v-if="lightboxIndex !== null"), so whenCloud.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 fromdocument(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 unmountedCloud.vueand 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>— greppingpause/waiting/stalledacrossMediaLightbox.vueanduseAudioPlayer.tsfinds 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.
- Tab-change part, checked against phase 2's lifecycle changes specifically — not
implicated. Neither
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 reproduce — uid 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:
- Instance-identity via
__vueParentComponent.uid(confirmed unconditionally set bynode_modules/@vue/runtime-core'smountElement, not gated behind__DEV__) — returnednullfor 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 undervitest'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. 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.document.querySelectorAll('.view-container').length+location.pathnameafter every hop — this instrument caught a REAL bug in the probe's own first iteration (not a hypothesis): the initial version'swaitFor({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 —pathnamelogging caught this directly (Server'safterVisit.pathnameread back/dashboard, not/dashboard/server). Fixed by gating onpage.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 distinctKEEP_ALIVE_PATHSentry 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.pathmismatch): instrument 3's pathname logging confirmslocation.pathnamematches 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 (
includecomma-split matching): none of the tenKeepWrap:<path>names contain a comma; Vue's ownmatches()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 inmeasure.ts's ownNEUTRAL_SELECTORconvention, uses) itself matches the exact same generic selector.Settings.vuerendersAccountSection(→AccountInfoSection.vue, hasdata-controller-container) andSystemSection(→KioskDisplaySection.vue, hasdata-controller-container) unconditionally — no tabs/accordion gating — and Settings' own root ALSO gets theview-containerfallthrough 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 isnullwhen 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 readtrue(survived) for Server in one run where the naive first-DOM-match method readfalse, andtrue(survived) for Web5 in a separate run where the naive method ALSO readfalse— direct, same-round-trip contradictions between "what's actually rendered on screen" and "what the naive selector-match probe reports." It never once readfalsefor 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 stillconnected: trueandvisible: 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 1–4 (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+ REALServer.vueat/dashboard/server, away-hops to a synthetic/dashboard/settings, returns, and assertsfindComponent(Server).vm.$.uidis 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 aKeepWrap:<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 indashboardViewWrappers.ts). - Test 4 (the bound stays bound):
shouldKeepAlivestill registers Server/Web5 andKEEP_ALIVE_MAXis 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 test — 95 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
1053–1386ms; 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 563–808ms;
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 255–606ms; after spread 696–1116ms; re-measure spread 1152–1723ms. 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
1065–1864ms; 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
432–895ms (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
2562–2619ms (n=3); after spread 2513–2722ms (n=3); re-measure spread 1729–2551ms (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: 600–3165ms 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 2116–4224ms (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:
neode-ui/src/views/fleet/useFleetData.ts:483-495(Fleet.vue's own data composable —Fleet.vuecallsuseFleetData()directly,const fleet = useFleetData()):onMounted(async () => { ... await refreshAll() if (autoRefresh.value) startAutoRefresh() // pollTimer = setInterval(..., 60000) }) onUnmounted(() => { stopAutoRefresh() }) // never fires again once Fleet is KeepAlive'dstartAutoRefresh()'spollTimerfiresPromise.all([fetchFleetStatus(), fetchFleetAlerts()])(plusfetchNodeHistory()if a node is selected) every 60 seconds, forever, from the first Fleet visit onward, regardless of whether Fleet is the visible dashboard tab.neode-ui/src/views/server/FipsNetworkCard.vue:265-274(rendered insideServer.vueat line 164,<FipsNetworkCard />):Thelet statusInterval: ReturnType<typeof setInterval> | null = null onMounted(() => { statusInterval = setInterval(() => { if (document.hidden) return; void statusRes.refresh() }, 15000) }) onUnmounted(() => { if (statusInterval) clearInterval(statusInterval) })document.hiddenguard only helps when the whole BROWSER TAB is backgrounded — it does nothing for an in-SPA dashboard-tab switch, sincedocument.hiddenstaysfalsethe entire time the Archipelago tab itself is foregrounded, regardless of which dashboard view is showing. This fires anfips.statusrefresh every 15 seconds, forever, from the first Server visit onward, once Server is KeepAlive'd.neode-ui/src/views/web5/Web5Monitoring.vue:105-114(rendered insideWeb5.vueat line 72,<Web5Monitoring />):onMounted(() => { loadStats(); refreshInterval = setInterval(loadStats, 30000) }) onBeforeUnmount(() => { if (refreshInterval) clearInterval(refreshInterval) })loadStats()callshomeStatus.refreshSystemStats()— the SAME store Home.vue's own, correctlyonActivated/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
navStepstransit/dashboard/serverdirectly (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## Methodsection 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 componentApps.vuerenders (AppCard.vue,AppIconGrid.vue,AppsUninstallModal.vue) found nosetIntervalat 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 theuseCachedResourcekeyed-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()wrapstartAutoRefresh()/stopAutoRefresh(), called fromonMounted+onActivated/onDeactivatedrespectively.toggleAutoRefresh()(the user's own Pause/Resume button) is untouched — a deactivated-then-reactivated Fleet still respects the user's own pause choice, sincearmFleetPoll()only restarts the timerif (autoRefresh.value).neode-ui/src/views/server/FipsNetworkCard.vue:armFipsPoll()/disarmFipsPoll()wrap the 15sstatusRes.refresh()poll the same way.useCachedResource's ownonActivatedrevalidation (already present, unaffected) still covers the staleness-gated background refresh independently.neode-ui/src/views/web5/Web5Monitoring.vue:armWeb5MonitoringPoll()/disarmWeb5MonitoringPoll()wrap the 30shomeStatus.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.