1233 lines
103 KiB
Markdown
1233 lines
103 KiB
Markdown
# 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:
|
||
|
||
```bash
|
||
cd neode-ui
|
||
ARCHY_BASE_URL=http://archi-dev-box \
|
||
ARCHY_PASSWORD=<the node's real UI password, provided out-of-band — never committed> \
|
||
ARCHY_PERF_OUT=../.planning/phases/02-ui-performance/02-PERF-BASELINE.json \
|
||
npx playwright test e2e/perf/surface-perf.spec.ts --project=chromium --reporter=line
|
||
```
|
||
|
||
**Sample count:** 3 runs per surface (`runs: 3` in the artifact header), each run
|
||
measuring a fresh first-visit (navigate from dashboard Home to the surface) and a revisit
|
||
(navigate to the neutral Settings tab — or close the surface's own trigger, for the
|
||
wallet-send modal — then return via the same UI-click path) in the same authenticated
|
||
session. `02-PERF-BASELINE.json`'s `firstVisitMs`/`revisitMs`/`revisitRpcCount` top-level
|
||
fields are the **median** across successful runs; `samples` holds every individual run
|
||
(including any per-run errors) so no data is discarded.
|
||
|
||
**Field meanings:**
|
||
- `firstVisitMs` / `revisitMs` — wall-clock ms from the click that starts navigation to
|
||
the surface's `contentSelector` becoming visible.
|
||
- `revisitRpcCount` — number of POSTs to `/rpc/v1` observed during the revisit window
|
||
(method name + timing only, never bodies — see the harness's threat mitigation).
|
||
- `maxConcurrentRpc` — peak number of RPC calls in flight simultaneously during the
|
||
revisit, derived by sweep-line over each call's `[start, start+duration]` interval. A
|
||
waterfall shows `maxConcurrentRpc: 1` with 2+ calls; a parallel fan-out shows
|
||
`maxConcurrentRpc` close to the total call count.
|
||
- `remounted` — read back from a dataset-stamp on the view's root element after the
|
||
away/back round-trip. `true` means the component instance was destroyed and recreated
|
||
(expected everywhere right now — `grep -r "KeepAlive" neode-ui/src` returns 0 hits,
|
||
confirmed again this session).
|
||
|
||
**All 15 SURFACES rows were run against archi-dev-box.** 13 of 15 produced clean
|
||
first-visit/revisit/RPC/remount numbers. 2 are recorded as `unmeasured` with the specific
|
||
reason (never as "already fast" — see the Mesh and Chat rows below).
|
||
|
||
**A methodological caveat that materially affects three rows' RPC evidence:** several
|
||
`navSteps` chains reach their target surface by first clicking through another main tab
|
||
(Marketplace and its own AppDetails go through Home's "Browse Store" link; OpenWrtGateway
|
||
goes through Server's "OpenWrt Gateway" link). Home.vue and Server.vue both fire a burst of
|
||
RPCs from their own `onMounted` the instant they're briefly mounted mid-chain
|
||
(`system.stats`, `fips.status`, `vpn.status`/`vpn.list-peers`, `wallet.*`,
|
||
`network.list-interfaces`, `tor.list-services`, etc.). Those in-flight requests are still
|
||
resolving when the chain lands on the destination surface, and the harness's RPC tracker —
|
||
which starts recording at the top of the revisit click-chain, not at the destination
|
||
surface's own mount — captures them as if they belonged to the destination. This was
|
||
confirmed by comparing captured method names directly: OpenWrtGateway's captured set
|
||
(`vpn.list-peers`, `network.list-interfaces`, `tor.list-services`, `fips.status`) is an
|
||
exact subset of Server.vue's own 8-call set measured cleanly on the `server` row (whose
|
||
`navSteps` do **not** transit another tab). Where this confound applies, the affected row's
|
||
Primary Cause is classified conservatively (`remount storm`, the reading the elevated
|
||
`revisitMs` alone supports) rather than `uncached fetch`, and the confound is called out
|
||
explicitly in that row's Intended Fix column so 02-02/02-03 re-verify with DevTools before
|
||
assuming the RPC count is real. Rows whose `navSteps` reach their target directly (Apps,
|
||
Cloud, Mesh, Server, Web5, Chat, AppDetails, CloudFolder) have no such confound — their RPC
|
||
counts are trustworthy as measured.
|
||
|
||
## Per-Surface Findings
|
||
|
||
| Surface | First visit (ms, median) | Revisit (ms, median) | Revisit RPC count | Max concurrent RPC | Remounted | Primary cause | Intended fix | Owning plan |
|
||
|---|---|---|---|---|---|---|---|---|
|
||
| Home (wallet figures) | 354 | 390 | 1 | 1 | true | already fast | Left alone (D-02) — still gets wrapped in `<KeepAlive>` per D-01 for scroll/animation-state persistence, no data-cache conversion needed | 02-04 (KeepAlive wrapping only) |
|
||
| Apps (My Apps) | 359 | 267 | 1 | 1 | true | already fast | Left alone (D-02) — KeepAlive wrapping only | 02-04 (KeepAlive wrapping only) |
|
||
| Marketplace (App Store) | 1228 | 2033 | 13 | 12 | true | remount storm | KeepAlive + convert to `useCachedResource`; **RPC count is confounded by the Home-transit artifact above — do not treat 13 calls as Marketplace's own fetch pattern without re-verifying via DevTools** | 02-02 (tracer/app store) |
|
||
| Discover (App Store tab) | 1117 | 1083 | 1 | 1 | true | remount storm | Convert to `useCachedResource` alongside Marketplace (same app-store surface family, D-09) | 02-02 (tracer/app store) |
|
||
| Cloud / Files | 463 | 247 | 0 | 0 | true | already fast | Left alone (D-02) — already on `useCachedResource` per code read; still gets KeepAlive wrapping | 02-04 (KeepAlive wrapping only) |
|
||
| Mesh | — | — | — | — | — | **unmeasured** | `.mesh-status-grid` never left the hidden/loading branch within the 20s wait window across all 3 runs — the device did not report `connected` on this node in the window measured. Re-run 02-05's own on-node pass once implementing; do not assume already-fast | 02-05 (Mesh) |
|
||
| Server (Network) | 969 | 738 | 8 | 7 | true | uncached fetch | Convert Server's 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):
|
||
|
||
1. **Wallet-send — 2607ms** (secondary/modal; anomalous revisit > first-visit, see note above)
|
||
2. **Marketplace — 2033ms** (main tab; RPC evidence confounded, see caveat)
|
||
3. **MarketplaceAppDetails — 1793ms** (secondary; RPC evidence confounded, see caveat)
|
||
4. **CloudFolder — 1212ms** (secondary; first-visit-dominant, ~5s cold chunk load)
|
||
5. **AppDetails — 1204ms** (secondary; clean uncached-fetch evidence)
|
||
6. **Discover — 1083ms** (secondary; remount storm)
|
||
7. **Server — 738ms** (main tab; clean uncached-fetch evidence)
|
||
8. **OpenWrtGateway — 663.5ms** (secondary; RPC evidence confounded)
|
||
9. **Web5 — 566ms** (main tab; remount storm, 0 RPC)
|
||
10. **Home — 390ms** (main tab; already fast)
|
||
11. **Fleet — 330ms** (main tab; already fast)
|
||
12. **Apps — 267ms** (main tab; already fast)
|
||
13. **Cloud — 247ms** (main tab; already fast)
|
||
|
||
Unmeasured: **Mesh**, **Chat** — see their rows above for why, and do not slot them into
|
||
this ranking as if they were fast.
|
||
|
||
**Tracer tab for plan 02-02:** among *main tabs* specifically (02-02's actual scope —
|
||
wallet-send and the secondary screens are not tabs), **Marketplace is the worst-performing
|
||
surface (2033ms revisit)** and is also the surface the user named first ("often app
|
||
store" — D-09). It is the tracer pick for 02-02, consistent with the plan set's existing
|
||
"02-02 tracer/app store" naming.
|
||
|
||
## Surfaces Left Alone (D-02)
|
||
|
||
These measured `already fast` on real hardware — no `useCachedResource` conversion is
|
||
planned for their data layer. Per D-01 they still get wrapped in `<KeepAlive>` as part of
|
||
02-04's shared Dashboard.vue restructure (scroll/animation-state persistence is free and
|
||
harmless even for an already-fast view), but that is template plumbing, not a per-view
|
||
data-caching change, so they are listed here rather than under any per-surface fix plan.
|
||
|
||
| Surface | First visit (ms) | Revisit (ms) | Revisit RPC | Why left alone |
|
||
|---|---|---|---|---|
|
||
| Home | 354 | 390 | 1 | Sub-400ms revisit with a single trivial `system.get-hostname` ping (not view-specific wallet data); wallet figures render from already-reactive store state |
|
||
| Apps | 359 | 267 | 1 | Sub-300ms revisit, single trivial ping; confirms RESEARCH.md's prediction that Apps.vue's data is WebSocket-pushed, not per-mount-fetched |
|
||
| Cloud | 463 | 247 | 0 | Sub-250ms revisit, zero RPC — confirms Cloud.vue's existing `useCachedResource` usage (`peersResource`/`countsResource`) is already preventing refetch |
|
||
| Fleet | 376 | 330 | 0 | Sub-350ms revisit, zero RPC |
|
||
|
||
## Corrections to Prior Research
|
||
|
||
**`ContainerAppDetails.vue` dead-code claim — CONFIRMED, stronger than RESEARCH.md stated.**
|
||
RESEARCH.md's Assumptions & Flagged Items (FA-B) reported the planner's grep found "only a
|
||
self-referential comment inside the file itself." Re-running the identical grep this
|
||
session found **zero matches at all**, including no self-reference:
|
||
|
||
```
|
||
$ grep -rn "ContainerAppDetails" neode-ui/src
|
||
(no output)
|
||
```
|
||
|
||
The file exists at `neode-ui/src/views/ContainerAppDetails.vue` (351 lines) but has no
|
||
importer anywhere in `neode-ui/src` and no route entry in `neode-ui/src/router/index.ts`
|
||
(verified directly against the full route table read for this plan's Task 1). It is
|
||
unreachable dead code.
|
||
|
||
**Impact:** RESEARCH.md named `ContainerAppDetails.vue:169-172`'s
|
||
`await loadContainer(); await loadLogs(); await loadHealthStatus()` as the phase's one
|
||
*confirmed* serial-RPC-waterfall fix target (Pattern 3). Since the file is unreachable, **no
|
||
plan in this phase should spend effort parallelizing it** — doing so would touch dead code
|
||
for zero user-visible benefit. More importantly: **no other surface in the measured D-09 set
|
||
shows a serial-waterfall signature.** Every surface in this findings doc with 2+ RPC calls on
|
||
revisit (Marketplace, Server, AppDetails, MarketplaceAppDetails, OpenWrtGateway) shows
|
||
`maxConcurrentRpc` at or near its total call count (7 of 8, 12 of 13, 12 of 14, 1 of 1-2, 4 of
|
||
4) — i.e. already parallel, not serial. **`serial RPC waterfall` is not used as a Primary
|
||
Cause anywhere in the Per-Surface Findings table above** because the measured evidence does
|
||
not support it for any reachable surface. This is the single most consequential correction
|
||
this profiling pass makes to RESEARCH.md: D-13's `Promise.all`/`Promise.allSettled`
|
||
parallelization pattern has no live target to apply to in this phase's D-09 surface set.
|
||
|
||
## Owning Plans
|
||
|
||
| Surface | Owning plan |
|
||
|---|---|
|
||
| Home | 02-04 (KeepAlive wrapping only — see Surfaces Left Alone) / 02-06 names Home in its title but the profiling shows no data-layer work is needed there |
|
||
| Apps | 02-04 (KeepAlive wrapping only) |
|
||
| Marketplace | 02-02 (tracer/app store) |
|
||
| Discover | 02-02 (tracer/app store) |
|
||
| Cloud | 02-04 (KeepAlive wrapping only) |
|
||
| Mesh | 02-05 (Mesh) |
|
||
| Server | 02-06 (Server and Home) |
|
||
| Web5 | 02-04 (KeepAlive wrapping only) |
|
||
| Fleet | 02-04 (KeepAlive wrapping only) |
|
||
| Chat | 02-07 (Chat/AIUI) |
|
||
| AppDetails | 02-03 (secondary screens) |
|
||
| MarketplaceAppDetails | 02-03 (secondary screens) |
|
||
| CloudFolder | 02-03 (secondary screens) |
|
||
| OpenWrtGateway | 02-03 (secondary screens) |
|
||
| Wallet / send flow | 02-03 (secondary screens) |
|
||
|
||
The shared foundation both 02-02 and every other per-surface plan depend on — the
|
||
Dashboard.vue nested-`<router-view>` KeepAlive restructure and the `useCachedResource`
|
||
`onActivated` extension — is owned by **02-04**, per RESEARCH.md's own recommendation to
|
||
land that shared work before converting individual views.
|
||
|
||
## Results
|
||
|
||
**Method:** Re-ran `neode-ui/e2e/perf/{surfaces,measure,surface-perf.spec}.ts` **unmodified**
|
||
(no harness file was edited to produce these numbers) against `archi-dev-box` — the same
|
||
target, same `runs: 3`, same `ARCHY_PERF_OUT` contract as the baseline. Run header:
|
||
`baseUrl: "http://archi-dev-box"`, `commit: "3ee20430"`, `runs: 3`, taken
|
||
`2026-07-31T01:27:30.384Z` (baseline was taken `2026-07-30T10:30:40.048Z` — a different
|
||
time of day on the same physical node; see the timing-variance note under Outstanding).
|
||
15/15 rows present (same count as baseline); Mesh and Chat are again `unmeasured`, for
|
||
reasons recorded per-row below.
|
||
|
||
**A new methodology finding, discovered and independently cross-verified this run (not
|
||
present at baseline time because no `<KeepAlive>` existed then):** `SURFACES.rootSelector`
|
||
for every main tab is the generic class `.view-container`, which `DashboardRouterView.vue`
|
||
falls through onto *every* routed view's own root (`<component :is="Component" :class="…
|
||
'view-container flex-none'" />` — see `dashboardViewWrappers.ts`/`DashboardRouterView.vue`).
|
||
Before this phase, only one view was ever mounted at a time, so `.view-container` was
|
||
unambiguous. Now that multiple main tabs are simultaneously KeepAlive-cached (up to
|
||
`KEEP_ALIVE_MAX`), **more than one `.view-container`-classed element can coexist in the
|
||
document at once**, and `measure.ts`'s `stampRoot`/`readRootProbe` (`document.querySelector`,
|
||
first DOM match) can silently stamp/read a *different* cached instance than the one actually
|
||
being tested — confirmed directly by DOM inspection (`document.querySelectorAll('.view-container').length`
|
||
returning 2 on a fresh visit to Marketplace, and the same call's *return order* changing
|
||
between the stamp step and the read-back step as Vue relocates deactivated subtrees into its
|
||
internal KeepAlive storage). This was not something I could fix by editing the harness (out of
|
||
this task's `files_modified`, and re-running the historical baseline commit to keep the pair
|
||
comparable would be invasive for a field — `remounted` — whose baseline value is trivially
|
||
`true` everywhere pre-KeepAlive regardless of selector precision). Instead, for every
|
||
main-tab row I independently re-verified `remounted` with a corrected, harness-independent
|
||
method: stamp/read the specific `.view-container` ancestor of the surface's own **visible**
|
||
`contentSelector` match (`getBoundingClientRect`/`offsetParent`, since KeepAlive's inactive
|
||
cached instances are not laid out on screen) rather than the first DOM match. Reproduced
|
||
twice for Server specifically to rule out a fluke. The **"Remounted (verified)"** column below
|
||
is this corrected reading; the **"Remounted (raw)"** column is the harness artifact's own
|
||
(sometimes confounded) field, kept visible rather than silently replaced.
|
||
|
||
| Surface | Kind | Baseline revisit ms | After revisit ms | Baseline RPC | After RPC | Remounted (raw, baseline→after) | Remounted (verified) | Verdict |
|
||
|---|---|---|---|---|---|---|---|---|
|
||
| Home | main-tab | 390 | 395 | 1 | 1 | true → true | **false (survives)** | improved — remount fixed; ms was already fast (D-02) and unaffected |
|
||
| Apps | main-tab | 267 | 184 | 1 | 1 | true → true | **false (survives)** | improved — faster ms and remount fixed |
|
||
| Marketplace | main-tab | 2033 | 683 | 13 | 8 | true → true | **false (survives)** | improved — 66% faster and remount fixed; RPC count still confounded by the Home-transit artifact 02-01 documented, unchanged this run |
|
||
| Discover | secondary (cache-registered) | 1083 | 1257 | 1 | 1 | true → true | not independently re-verified | regressed — revisit got slower; raw remounted unchanged |
|
||
| Cloud | main-tab | 247 | 129 | 0 | 0 | true → true | **false (survives)** | improved — faster ms and remount fixed |
|
||
| Mesh | main-tab | — | — | — | — | — | — | **unmeasured** — `.mesh-status-grid` stayed hidden 20s across all 3 runs; device not reporting `connected` in this window, identical reason to baseline |
|
||
| Server | main-tab | 738 | 849 | 8 | 2 | true → true | **true (genuinely remounts)** | **regressed** — slower ms AND the KeepAlive promise is not delivered for this tab, confirmed by two independent reproductions of the corrected probe. RPC count did drop (8→2, real `useCachedResource` benefit), but the surface still fully remounts on revisit |
|
||
| Web5 | main-tab | 566 | 709 | 0 | 0 | true → true | **false (survives)** | regressed on ms despite remount being fixed — a split signal (0 RPC either time, so the added ~140ms is pure client-side re-render/re-activation cost on an instance that isn't actually being destroyed); flagged for direct profiling, not fixed here |
|
||
| Fleet | main-tab | 330 | 1054 | 0 | 0 | true → true | **false (survives)** | **regressed, prominently** — 3.2× slower on revisit despite the instance surviving and zero RPC either time; the same split-signal pattern as Web5 but much larger. Reached via a 2-hop chain through Web5 first, so some of this may be transition-chain noise, but the raw number is recorded as measured, not adjusted |
|
||
| Chat | main-tab | — | — | — | — | — | — | **unmeasured** — but for a *different* specific reason than baseline: baseline's block was the AIUI connecting-overlay outliving the close-button click budget; this run's block is the disk-usage `HealthNotifications.vue` toast (see below) intercepting `.chat-close-btn` for the same reason it now interferes elsewhere. Recorded as unmeasured either way, per the plan's own rule that an unmeasured surface is never presented as improved |
|
||
| AppDetails | secondary | 1204 | 1510 | 2 | 1 | true → true | not independently re-verified (expected to remount; D-04 doesn't cache secondary screens) | regressed — revisit got slower despite one fewer RPC call |
|
||
| MarketplaceAppDetails | secondary | 1793 | 1532 | 14 | 8 | true → **false** | not independently re-verified | improved on ms; the raw `remounted: false` reading is very likely the same rootSelector-ambiguity artifact described above (its `navSteps` transit a KeepAlive-cached Marketplace instance immediately before reaching the detail page) rather than evidence this secondary screen is now incorrectly instance-cached — flagged, not asserted either way. RPC count improvement is still confounded per 02-01's original note (13 of 14 baseline calls were Home-transit bleed-through; only `package.versions` was trustworthy) |
|
||
| CloudFolder | secondary | 1212 | 985 | 0 | 0 | true → true | not independently re-verified | improved — faster ms, 0 RPC both times as before |
|
||
| OpenWrtGateway | secondary | 663.5 | 1148 | 4 | 2 | true → true | not independently re-verified | regressed — revisit got slower; RPC count still confounded per 02-01's original note (captured methods are a subset of Server's own call set) |
|
||
| Wallet / send flow | secondary (modal) | 2607 | 2556 | 0 | 0 | true → true | not independently re-verified (modal always remounts via `v-if`, by design) | unchanged — the anomaly 02-01 flagged (revisit slower than first-visit, pure client-side cost, 0 RPC) persists; no plan's `files_modified` touched `SendBitcoinModal.vue` (see Outstanding) |
|
||
|
||
## Outstanding
|
||
|
||
- **Server.vue does not survive a tab round-trip on real archi-dev-box hardware**, despite
|
||
being registered in `KEEP_ALIVE_PATHS` since 02-04 and 02-04's own Task 3 checkpoint
|
||
reporting it as approved. Confirmed genuinely (not a measurement artifact) via two
|
||
independent reproductions of a corrected, harness-independent remount probe. This directly
|
||
violates this plan's own must-have truth ("every main-tab path registered in
|
||
`KEEP_ALIVE_PATHS` has `After remounted` false"). Root cause not investigated here — Task 2's
|
||
scope is measurement, not per-view debugging, and this is a pre-existing 02-04 behavior, not
|
||
something this plan's own files touched. **Needs a follow-up plan or a targeted fix to
|
||
Server.vue/its KeepAlive wrapper registration before PERF-02's per-tab claim can be
|
||
considered fully verified for this specific tab.**
|
||
- **Web5 and (especially) Fleet revisit ms regressed significantly despite their component
|
||
instances now genuinely surviving the round-trip** (confirmed via the corrected probe) and
|
||
zero RPC calls either visit. This is a split signal: the KeepAlive architecture is working
|
||
(no remount, no refetch) but something client-side is adding real wall-clock cost on
|
||
reactivation — Fleet's 330ms→1054ms (3.2×) is the more severe of the two. Not profiled or
|
||
fixed here; flagged for direct DevTools/Performance-panel investigation, the same class of
|
||
work 02-01 already flagged for Wallet-send's anomaly and never resolved.
|
||
- **Discover, AppDetails and OpenWrtGateway all regressed on revisit ms** without an
|
||
independent remount re-verification (not main tabs subject to the plan's KeepAlive
|
||
must-have truth, so not re-probed under time constraints); OpenWrtGateway's RPC count is
|
||
still confounded per 02-01's original note (its captured methods are a subset of Server's
|
||
own call set from the Server-transit in its `navSteps`) and needs DevTools re-verification
|
||
before treating the RPC drop as this screen's own fix rather than confound noise shifting.
|
||
- **MarketplaceAppDetails's raw `remounted: false` reading is very likely a rootSelector-
|
||
ambiguity artifact**, not evidence this secondary screen picked up incidental instance
|
||
caching — flagged, not independently re-verified given time constraints. If a future plan
|
||
needs to confirm D-04 (secondary screens are NOT instance-cached) still holds for this
|
||
screen specifically, re-run the corrected probe method documented above against it directly.
|
||
- **Wallet-send's anomaly (revisit slower than first-visit, 0 RPC either time) is still
|
||
unaddressed** — 02-03 reported it as an unplanned-item gap (owned by 02-03 per this
|
||
findings doc, but `SendBitcoinModal.vue` never appeared in any plan's `files_modified`).
|
||
Carried forward unresolved.
|
||
- **CloudFolder.vue's residual TTL gap**: `cloudStore.navigate()` (`cloud.ts`) still always
|
||
re-fetches on revisit — it just doesn't block paint. 02-03 explicitly left this as a gap
|
||
(outside its `files_modified`) rather than bolting on a second, parallel cache. Still open.
|
||
- **PeerFiles.vue** was found by 02-03 to not actually use `useCachedResource` (contrary to
|
||
that plan's own `read_first` assumption) and force-refetches unconditionally on every mount
|
||
with the same loading/refreshing conflation bug 02-03 fixed elsewhere. Flagged by 02-03, not
|
||
fixed by any plan since. Still open.
|
||
- **`/dashboard/settings` remains withheld from `KEEP_ALIVE_PATHS`** (02-04's own deliberate
|
||
exclusion, unaudited risk — `SystemDangerZone.vue`'s reboot poll, several `onMounted`-only
|
||
fetches in its child sections). Not part of this phase's scope to resolve; carried forward
|
||
as a known gap for whichever future plan audits Settings.
|
||
- **`ContainerAppDetails.vue`** reconfirmed fully unreachable dead code (02-01, re-confirmed
|
||
02-03). A candidate for a follow-up cleanup deletion, not part of this phase.
|
||
- **archy-x250-dev (the dev pair's second node) was offline** for both this plan's deploy
|
||
(Task 1) and this re-measurement — `tailscale status` showed it "offline, last seen 2h ago"
|
||
at Task 1 and unchanged when re-checked before Task 2. Only `archi-dev-box` received this
|
||
phase's build and was measured. D-11's verification target is `archi-dev-box` specifically,
|
||
so this does not block the plan, but the second dev-pair node has not received this phase's
|
||
changes at all and should get the same `--frontend-only` deploy once it's back online.
|
||
- **Chat/Mesh remain unmeasured**, same as baseline for Mesh (device not reporting
|
||
`connected`); Chat's specific blocking cause changed from an AIUI-connection timeout
|
||
(baseline) to a disk-usage toast intercepting the close button (this run) — both are
|
||
environmental/UI-affordance issues rather than something either run's numbers can speak to.
|
||
The disk-usage toast (`HealthNotifications.vue`, no `pointer-events: none` on its
|
||
`.fixed.right-4.z-[200]` wrapper) intercepting clicks elsewhere on the page is itself worth a
|
||
follow-up fix independent of this phase — the node's disk is genuinely at 85% right now,
|
||
so the toast is a real, current, recurring condition, not a one-off.
|
||
- **Baseline vs. after timing-variance caveat**: the baseline was taken at 10:30 local time;
|
||
this after-run at 01:27 the next day — different system load on the same physical,
|
||
multi-service node (85% disk, live containers, this same box also serving as the build
|
||
machine) may contribute to some of the regressed rows above independent of any code change.
|
||
This is recorded as an interpretive note, not used to soften any verdict above — every
|
||
regressed row is reported as regressed with its measured numbers. A same-time-of-day re-run
|
||
would help disambiguate real regression from environmental noise for Discover/Server/Web5/
|
||
Fleet/AppDetails/OpenWrtGateway.
|
||
- **Assumptions & Flagged Items from the plan set not yet settled by this run**: FA-D
|
||
(`KEEP_ALIVE_MAX`) was settled in Task 1 (see its SUMMARY — confirmed at 6 via an on-device
|
||
memory reading). FA-B (`ContainerAppDetails.vue`) — see the dead-code note above. FA-E
|
||
(AIUI source) — settled by 02-07 (source located, D-14 landed upstream).
|
||
|
||
## Addendum: Task 3 Checkpoint Follow-Up (2026-07-31, post-first-pass)
|
||
|
||
The user's first Task 3 pass reported "otherwise it's getting much better" plus four specific
|
||
issues. Each was investigated on archi-dev-box directly (not guessed), root-caused via git
|
||
history, and — where it traced to a phase-2 commit — fixed. No new plan was created per
|
||
standing direction; this addendum records the investigation and its outcome.
|
||
|
||
### Real-node phase-2 regression: first visit to Cloud, no folder opened on click
|
||
|
||
**Confirmed, root-caused, and fixed — verified 5/5 fresh sessions against the redeployed build.**
|
||
Reproduced deterministically on archi-dev-box (both `chrome-headless-shell` and full Chromium —
|
||
not a test-harness artifact) with a genuinely fresh session.
|
||
|
||
- The reported symptom ("clicking a folder does nothing") is a **downstream effect**, not a
|
||
click or router bug. Direct instrumentation proved: the click event fires (confirmed via a
|
||
raw DOM listener attached to the exact clicked node), `router.push({name:'cloud-folder',...})`
|
||
is called (confirmed by patching the live `$router` instance), but the returned promise never
|
||
settles — because Vue Router awaits the target route's async component, and that dynamic
|
||
`import()` itself never resolves.
|
||
- Confirmed via manual `import()` calls from the page console: after visiting Cloud once, importing
|
||
**any** lazy route chunk (Fleet, CloudFolder, AppDetails — unrelated views) hangs identically;
|
||
before ever visiting Cloud, the same imports resolve in ~300ms. Not chunk-specific.
|
||
- Traced to exactly one permanently-pending network request: `GET /app/filebrowser/api/resources/Photos`,
|
||
fired from `Cloud.vue`'s `fetchCounts()` on mount. Confirmed hung via request-lifecycle
|
||
tracking (`request` fires, neither `requestfinished` nor `requestfailed` ever fires, still
|
||
pending after 10s). The **identical** request, issued manually with a fresh token completely
|
||
outside of Cloud.vue, returns in 29ms — ruling out File Browser or the network path itself.
|
||
Once that one request is stuck, Chromium's per-origin connection pool appears to starve every
|
||
other same-origin fetch for the rest of the session, including the lazy chunks any later
|
||
navigation needs.
|
||
- **Fix #1 applied** (`fix(02-08)`, commit `e1a3f31a`, `neode-ui/src/views/Cloud.vue`): gave
|
||
`Cloud.vue`'s `syncOnEntry()` the same fresh-mount guard already used in
|
||
`Home.vue`/`Web5.vue`/`Mesh.vue`/`Server.vue` (02-04 exempted Cloud on the reasoning each
|
||
individual resource is staleness/inflight-deduped — true per-resource, but the two back-to-back
|
||
`onMounted`+`onActivated` passes still doubled `loadPeerFiles()`'s full per-peer
|
||
`content.browse-peer` fan-out in the same tick, stacking on whatever else was mounting at the
|
||
same first-activation instant). This reduced but did not eliminate the hang (re-reproduced 5/5
|
||
against the redeployed build) — ruled out a `chrome-headless-shell`-only artifact (reproduces
|
||
in full Chromium too) and raw request concurrency alone (an artificial 30-concurrent-request
|
||
burst against the same `/Photos` endpoint, done outside Cloud.vue, completed in 179ms, no hang).
|
||
- **Root cause fully isolated via direct instrumentation** (request-lifecycle tracking of every
|
||
`content.browse-peer` call during a fresh Cloud mount): **13 of 14** concurrent
|
||
`content.browse-peer` requests never settled at all within 15s+ (only the one peer that was
|
||
actually reachable resolved, in 1.4s). `loadPeerFiles()` fanned these out with **zero
|
||
concurrency cap** and a 30s per-call timeout — that many simultaneously open, indefinitely-
|
||
pending same-origin requests (dead/unreachable peers, no server-side timeout on that RPC path)
|
||
is what starved Chromium's connection pool, not raw request volume in general.
|
||
- **Fix #2 applied** (`fix(02-08)`, commit `8fe6217b`, `neode-ui/src/views/Cloud.vue`): capped the
|
||
`content.browse-peer` fan-out at 3 concurrent requests (`BROWSE_PEER_CONCURRENCY`, a
|
||
queue/worker pool) and shortened each call's timeout from 30s to 10s
|
||
(`BROWSE_PEER_TIMEOUT_MS`), mirroring `PeerFiles.vue`'s existing `PREVIEW_CONCURRENCY` pattern
|
||
already in this codebase for the identical class of problem (`content.preview-peer`'s own
|
||
fan-out). A timed-out/failed peer already resolved silently through `resources.ts`'s own
|
||
error-state path (no throw, no toast) — confirmed unchanged; the muted "N peers unreachable —
|
||
showing what answered" line is still the only surface, no new UI. Tested (95 files/774 tests
|
||
green, type-check and build clean, `keepAliveTabs.test.ts` structural assertions untouched),
|
||
committed, pushed, redeployed to archi-dev-box the same way as Task 1.
|
||
- **Verified resolved**: 5/5 fresh sessions (brand-new browser context each run, no shared
|
||
storage) now navigate cleanly on the first folder click, confirmed both by URL and by the
|
||
folder's content actually rendering. Directly re-confirmed the mechanism is closed: zero
|
||
in-flight (hung) requests after 15s on the Cloud tab (previously exactly one, permanently
|
||
pending), and a lazy route chunk (`Fleet-*.js`) that previously hung forever now imports in
|
||
17ms after the same 15s dwell on Cloud.
|
||
- **Closed.** Both contributing defects (the redundant dual-fire pass, and the unbounded/
|
||
unbounded-duration peer fan-out) are fixed and independently verified on real hardware.
|
||
|
||
### Classification of the other three reported issues — all pre-existing, none phase-2
|
||
|
||
All three traced via `git log`/`git merge-base --is-ancestor` against `a75b6709` (the commit the
|
||
02-01 baseline was taken at, i.e. the last commit before any phase-2 UI change landed). None of
|
||
the implicated files or functions were touched by any phase-2 commit; no fix was implemented for
|
||
any of these three, per standing direction (captured separately as phase-1/general UX work).
|
||
|
||
1. **Paid Files: clicking a picture opens it in a browser tab instead of the app's lightbox.**
|
||
`neode-ui/src/views/Cloud.vue` `viewPaidItem()` (~line 470-493): for any non-audio purchased
|
||
item it does `window.open(url, '_blank', 'noopener')` on a blob URL — it never calls
|
||
`MediaLightbox.vue` (`neode-ui/src/components/cloud/MediaLightbox.vue`) at all, unlike the My
|
||
Files / Peer Files rows' `handlePreview()` paths, which do route into the lightbox. Introduced
|
||
in `f3393581` ("fix(content): double-pay is now impossible + purchases auto-file + Paid Files
|
||
tab", 2026-07-22 — confirmed an ancestor of `a75b6709` via `git merge-base --is-ancestor`).
|
||
**Pre-existing, not phase 2.**
|
||
2. **Picture-in-picture on a video doesn't close the lightbox; the transition should be fluid.**
|
||
`neode-ui/src/utils/pip.ts`'s `togglePip()` only calls
|
||
`video.requestPictureInPicture()`/`exitPictureInPicture()` — it never emits a close event or
|
||
touches `MediaLightbox.vue`'s (`neode-ui/src/components/cloud/MediaLightbox.vue`, PiP button
|
||
at ~line 21-31, `close()` at ~line 233) own visibility state, so the lightbox backdrop simply
|
||
stays open behind the PiP window with no reaction and no transition at all. Introduced in
|
||
`f72d4b92` ("feat(content): seller-picked payment methods + music always in the bottom bar +
|
||
video PiP", 2026-07-23 — zero commits touch `MediaLightbox.vue` or `pip.ts` between
|
||
`a75b6709` and `HEAD`, confirmed via `git log a75b6709..HEAD --`).
|
||
**Pre-existing, not phase 2.**
|
||
3. **Some surfaces lack loader states where an open genuinely takes time.**
|
||
Spot-checked rather than exhaustively swept (a full sweep of every secondary-screen/modal
|
||
open across the app is its own piece of work). The clearest concrete instance found:
|
||
`neode-ui/src/views/Cloud.vue` `viewPaidItem()` (same function as #1 above) makes a
|
||
`content.owned-get` RPC call with a 60-second timeout and shows **no loading indicator at
|
||
all** while it resolves — `paidLoading` (line 466) only covers the list fetch
|
||
(`content.owned-list`), not the per-item open. Clicking a Paid Files row shows nothing until
|
||
the new tab appears (or fails silently). Same commit (`f3393581`, 2026-07-22) as #1 — **pre-
|
||
existing, not phase 2**. Other candidates were not individually verified in the time
|
||
available; `CloudFolder.vue`'s native file-grid path does already show a loading skeleton
|
||
(`neode-ui/src/components/cloud/FileGrid.vue` line 4), so that one specific secondary screen
|
||
is not part of this finding.
|
||
4. **Picture-in-picture must survive tab changes or video buffering pauses; doesn't seem to.**
|
||
Two parts, classified separately:
|
||
- **Tab-change part, checked against phase 2's lifecycle changes specifically — not
|
||
implicated.** Neither `Cloud.vue`, `CloudFolder.vue`, nor `MediaLightbox.vue`
|
||
(`neode-ui/src/components/cloud/MediaLightbox.vue`) defines an `onDeactivated` hook at all
|
||
(zero matches for `onDeactivated` in any of the three) — 02-04 never touched this file and
|
||
added no teardown that pauses, resets, or unmounts video/lightbox state on tab
|
||
deactivation. `MediaLightbox` is a `<Teleport to="body">` child rendered from inside
|
||
`Cloud.vue`'s template (`v-if="lightboxIndex !== null"`), so when `Cloud.vue` (KeepAlive-
|
||
cached since 02-04) deactivates on a tab switch, Vue's own KeepAlive machinery recursively
|
||
deactivates that whole subtree, including the teleported lightbox/video, moving its DOM
|
||
into KeepAlive's internal (not document-attached) storage container. Whether Vue's exact
|
||
move semantics disconnect the `<video>` element from `document` (which would explain a
|
||
native PiP session ending, per browser spec) was not conclusively provable from source
|
||
reading alone. What IS provable, and settles the phase-2 question either way:
|
||
pre-phase-2, switching tabs fully unmounted `Cloud.vue` and destroyed its video element
|
||
outright (zero `<KeepAlive>` existed anywhere before 02-02) — a guaranteed PiP-ending
|
||
event, strictly worse than or equal to whatever KeepAlive's deactivation does now. Phase 2
|
||
could not have regressed "PiP survives a tab change" because that behavior never existed
|
||
before phase 2 to regress from. **Pre-existing/never-supported, not a phase-2 regression**
|
||
— a real UX gap worth phase 1's UIFIX-05 attention, but not something 02-04 broke.
|
||
- **Buffering-pause part — pre-existing, and there is no custom handling to point at.**
|
||
`MediaLightbox.vue`'s `<video>` element (~line 77-87) is a bare
|
||
`<video controls autoplay @dblclick @error>` — grepping `pause`/`waiting`/`stalled` across
|
||
`MediaLightbox.vue` and `useAudioPlayer.ts` finds zero listeners on the video element for
|
||
those events. There is no app-level rebuffering/PiP-continuity logic to have regressed;
|
||
PiP-during-rebuffer behavior here is entirely native browser/HTML5-video default behavior,
|
||
untouched by any app code in any phase. **Pre-existing (by absence), not phase 2.**
|
||
|
||
## Server KeepAlive Root Cause (gap closure)
|
||
|
||
**Named cause (one sentence):** Server.vue's (and, discovered mid-investigation, Web5.vue's)
|
||
component instance genuinely survives every tab round-trip on archi-dev-box exactly like every
|
||
other registered `KEEP_ALIVE_PATHS` tab — the "remounts" verdict this plan set out to root-cause
|
||
is a confirmed **measurement artifact** of the remount probe's own selector, not a real KeepAlive
|
||
or component-lifecycle defect; the underlying architecture (`DashboardRouterView.vue` /
|
||
`dashboardViewWrappers.ts` / `keepAliveRoutes.ts`) works correctly for these tabs and needs no fix.
|
||
|
||
### Method
|
||
|
||
Per D-10's cheapest-discriminator-first order:
|
||
|
||
**Step A (jsdom reproduction, no device):** Extended `keepAliveLifecycle.test.ts`'s pattern with
|
||
a new test mounting the REAL `DashboardRouterView` + REAL `Server.vue` (both a directly-imported
|
||
and an `() => import(...)`-based async-component route, matching production's own route
|
||
definition exactly) at `/dashboard/server`, doing an away-hop to `/dashboard/settings` and back,
|
||
comparing `findComponent(Server).vm.$.uid` (Vue's internal per-instance monotonic id) before and
|
||
after. **Did not 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:
|
||
|
||
1. **Instance-identity via `__vueParentComponent.uid`** (confirmed unconditionally set by
|
||
`node_modules/@vue/runtime-core`'s `mountElement`, not gated behind `__DEV__`) — returned
|
||
`null` for every reading against the deployed build. Confirmed this is the production bundle
|
||
stripping the devtools hook (`__VUE_PROD_DEVTOOLS__` defaults off), not a probe bug: the
|
||
property is genuinely absent from every root, survivors and non-survivors alike, on the real
|
||
deployed build, while the identical code path DOES populate it under `vitest`'s dev-mode Vue
|
||
build (used to confirm Step A). This instrument was inconclusive on-device by construction —
|
||
an honest limitation, not a finding either way.
|
||
2. **`page.on('pageerror')`/`page.on('console')`**, captured for the ENTIRE session (not just
|
||
per-surface — session-wide capture added after per-surface listeners risked missing a
|
||
delayed/async error) — across four independent full-session runs, **zero** errors or warnings
|
||
were ever attributed to Server or Web5's activation/deactivation. Suspect 2 (a runtime error
|
||
tearing down the cached subtree) has no supporting evidence.
|
||
3. **`document.querySelectorAll('.view-container').length` + `location.pathname`** after every
|
||
hop — this instrument caught a REAL bug in the probe's own first iteration (not a hypothesis):
|
||
the initial version's `waitFor({state:'visible'})` on the shared, generic
|
||
`.view-container [data-controller-container]` selector could resolve BEFORE the navigation
|
||
actually landed, because the PREVIOUS tab's own matching content was still visible — `pathname`
|
||
logging caught this directly (Server's `afterVisit.pathname` read back `/dashboard`, not
|
||
`/dashboard/server`). Fixed by gating on `page.waitForURL()` matching the exact literal path
|
||
before checking content visibility.
|
||
|
||
**Suspects eliminated by direct measurement (not by inspection alone):**
|
||
|
||
- **Suspect 3 (LRU eviction, `KEEP_ALIVE_MAX=6`):** instrument 3's viewContainerCount trace,
|
||
combined with the exact SURFACES-order navigation sequence, shows no NEW distinct
|
||
`KEEP_ALIVE_PATHS` entry is added between any surface's own away-then-return hop — the cap
|
||
cannot be the mechanism evicting Server/Web5 specifically during their own isolated round trip.
|
||
**Eliminated.**
|
||
- **Suspect 4 (`route.path` mismatch):** instrument 3's pathname logging confirms
|
||
`location.pathname` matches the surface's literal registered path exactly on every visit and
|
||
return, for all ten paths including Server and Web5, across every run. **Eliminated.**
|
||
- **Suspect 5 (`include` comma-split matching):** none of the ten `KeepWrap:<path>` names contain
|
||
a comma; Vue's own `matches()` only splits on commas, so this holds identically and vacuously
|
||
for every registered path — structurally proven, not just measured. **Eliminated.**
|
||
- **Suspect 2 (Server-specific runtime error):** zero captured errors/warnings across four
|
||
independent full-session-captured device runs, plus a real jsdom reproduction attempt covering
|
||
the exact real components in the exact real sequence found no error either. **Not supported by
|
||
any evidence gathered.**
|
||
|
||
**Suspect 1 (probe measures DOM-element identity, not component-instance identity) — CONFIRMED,
|
||
with direct proof, and refined beyond its original framing.** The generic
|
||
`.view-container [data-controller-container]` / `.view-container` pair — shared by Server, Web5
|
||
AND Fleet, and structurally shared by every OTHER main tab too, since `DashboardRouterView.vue`
|
||
applies the `view-container` class via fallthrough to every non-full-bleed routed view's own
|
||
root (`:class="isFullBleedPath(route.path) ? undefined : 'view-container flex-none'"`) — cannot
|
||
reliably distinguish "the tab actually visible on screen right now" from "a different
|
||
KeepAlive-cached tab whose root happens to still be connected to the document" once more than
|
||
one main tab has been visited (the NORMAL, intended state — that is the entire point of
|
||
KeepAlive). Every `.view-wrapper` is `position: absolute; inset: 0`, so ANY currently-mounted
|
||
alternative view's root reports a non-zero `getBoundingClientRect()` and a non-null
|
||
`offsetParent` — the exact "visible" check the corrected 02-08 method (and this plan's own
|
||
Step-B probe, initially) used — REGARDLESS of whether that view is the true foreground tab.
|
||
`Array.prototype.find()`'s first-DOM-order match has no way to tell these apart.
|
||
|
||
Two additional discoveries sharpened this beyond a hypothesis into direct, decisive proof:
|
||
|
||
- **`/dashboard/settings` (the away tab EVERY round trip in this probe, and in `measure.ts`'s own
|
||
`NEUTRAL_SELECTOR` convention, uses) itself matches the exact same generic selector.**
|
||
`Settings.vue` renders `AccountSection` (→ `AccountInfoSection.vue`, has
|
||
`data-controller-container`) and `SystemSection` (→ `KioskDisplaySection.vue`, has
|
||
`data-controller-container`) unconditionally — no tabs/accordion gating — and Settings' own
|
||
root ALSO gets the `view-container` fallthrough class like every other non-full-bleed route.
|
||
Its leave-transition keeps that DOM genuinely connected and "visible" by the offsetParent/rect
|
||
check for the transition's full duration while the target's enter-transition is already
|
||
progressing.
|
||
- **Adding an authoritative, independent signal — `document.elementFromPoint()` at the viewport
|
||
center (real hit-testing, respects actual stacking/z-index, unlike the offsetParent heuristic)
|
||
— directly contradicted the naive method's "remounted" verdict.** Across the runs where this
|
||
signal resolved to a definite answer at all (it is `null` when the exact center pixel doesn't
|
||
land inside a `.view-container`-classed element, which happens often enough to not be usable
|
||
as the SOLE signal): it read **`true` (survived) for Server** in one run where the naive
|
||
first-DOM-match method read **`false`**, and **`true` (survived) for Web5** in a separate run
|
||
where the naive method ALSO read **`false`** — direct, same-round-trip contradictions between
|
||
"what's actually rendered on screen" and "what the naive selector-match probe reports." It
|
||
never once read `false` for either Server or Web5 across all decisive runs. A companion
|
||
diagnostic — enumerating every `.view-container`-matching root in the document at read-back
|
||
time with its own stamp mark — independently confirmed the same thing directly: one read for
|
||
Web5 found the ORIGINAL stamped root still `connected: true` and `visible: true` (by the
|
||
offsetParent check), simply not the one the naive `.find()` picked first.
|
||
|
||
### Verdict
|
||
|
||
Server.vue's and Web5.vue's component instances survive a tab round-trip on archi-dev-box —
|
||
proven directly via an authoritative, hit-test-based signal that independently contradicts the
|
||
"remounted" reading the naive selector-based method (both 02-08's and this plan's own first-pass
|
||
correction) produces for these two specific tabs. This is not a guess or an absence-of-evidence
|
||
argument: it is a positive, repeated (elementFromPoint agreed with survival in every run where it
|
||
resolved a verdict at all — zero contradicting readings) observation that the actual foreground,
|
||
user-visible element at read-back time is the SAME element this probe stamped at visit time.
|
||
|
||
Per the plan's own explicitly anticipated branch: **the earlier "genuinely remounts" reading (in
|
||
02-08 and in this plan's own early iterations) was a probe artifact, not a real defect.** Server
|
||
and Web5 are — like Fleet — surfaces whose `contentSelector`/`rootSelector` happens to be the
|
||
fully generic pair shared by every KeepAlive-cached main tab, which makes THEIR OWN measurement
|
||
uniquely vulnerable to this artifact even though the underlying KeepAlive architecture treats them
|
||
identically to every reliably-measured tab (Home, Apps, Marketplace, Discover, Cloud, Chat — all
|
||
of which have their own unique CSS class and never hit this ambiguity).
|
||
|
||
**Fleet is NOT independently re-confirmed either way here** (out of this gap's named scope —
|
||
02-08 already reported it surviving with a large timing regression, unaffected by this finding);
|
||
`elementFromPointSurvived` never resolved to a definite verdict for Fleet across the runs
|
||
performed (its viewport center did not land inside a matching root at read-back time), so this
|
||
finding neither confirms nor overturns 02-08's existing Fleet verdict.
|
||
|
||
**Intended fix (Task 2):** No change to `DashboardRouterView.vue`, `dashboardViewWrappers.ts`,
|
||
`keepAliveRoutes.ts` or `Server.vue`'s KeepAlive/lifecycle wiring — the architecture already
|
||
delivers PERF-02's instance-caching promise for this tab. Task 2 lands Tests 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` + REAL `Server.vue` at
|
||
`/dashboard/server`, away-hops to a synthetic `/dashboard/settings`, returns, and asserts
|
||
`findComponent(Server).vm.$.uid` is IDENTICAL before and after. **Passed immediately, first
|
||
try, against the unmodified code** — no RED phase was possible or expected, because this is
|
||
the no-change branch: the plan's own instruction ("this branch is only available on positive
|
||
proof of survival... land Tests 1-4, they will pass immediately, which is itself the pin")
|
||
describes exactly this outcome. A failing-before-fix observation does not exist here because
|
||
there was never a fix.
|
||
- **Test 2 (no collateral damage):** the same round trip through the REAL `Web5.vue` (the other
|
||
surface this investigation implicates) plus a synthetic second tab, both keeping their
|
||
instance-uid / mount-count at exactly 1 across the round trip. **Passed immediately.**
|
||
- **Test 3 (registration is really the include list):** every `keepAliveIncludeNames()` entry is
|
||
a `KeepWrap:<path>` name for a registered path, contains no comma, and `/dashboard/server`'s and
|
||
`/dashboard/web5`'s wrapper names are both present. **Passed** (already true, unconditionally,
|
||
from the existing derivation in `dashboardViewWrappers.ts`).
|
||
- **Test 4 (the bound stays bound):** `shouldKeepAlive` still registers Server/Web5 and
|
||
`KEEP_ALIVE_MAX` is still 6, unchanged. **Passed** — the pre-existing 02-04 eviction test in the
|
||
same file (`visiting more distinct registered tabs than KEEP_ALIVE_MAX...`) is untouched and
|
||
still green, confirming the cap itself was never in question.
|
||
|
||
**Verification run:** full `npm 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:
|
||
|
||
1. **`neode-ui/src/views/fleet/useFleetData.ts:483-495`** (Fleet.vue's own data composable —
|
||
`Fleet.vue` calls `useFleetData()` directly, `const fleet = useFleetData()`):
|
||
```
|
||
onMounted(async () => {
|
||
...
|
||
await refreshAll()
|
||
if (autoRefresh.value) startAutoRefresh() // pollTimer = setInterval(..., 60000)
|
||
})
|
||
onUnmounted(() => { stopAutoRefresh() }) // never fires again once Fleet is KeepAlive'd
|
||
```
|
||
`startAutoRefresh()`'s `pollTimer` fires `Promise.all([fetchFleetStatus(), fetchFleetAlerts()])`
|
||
(plus `fetchNodeHistory()` if a node is selected) every 60 seconds, forever, from the first
|
||
Fleet visit onward, regardless of whether Fleet is the visible dashboard tab.
|
||
2. **`neode-ui/src/views/server/FipsNetworkCard.vue:265-274`** (rendered inside `Server.vue` at
|
||
line 164, `<FipsNetworkCard />`):
|
||
```
|
||
let statusInterval: ReturnType<typeof setInterval> | null = null
|
||
onMounted(() => {
|
||
statusInterval = setInterval(() => { if (document.hidden) return; void statusRes.refresh() }, 15000)
|
||
})
|
||
onUnmounted(() => { if (statusInterval) clearInterval(statusInterval) })
|
||
```
|
||
The `document.hidden` guard only helps when the whole BROWSER TAB is backgrounded — it does
|
||
nothing for an in-SPA dashboard-tab switch, since `document.hidden` stays `false` the entire
|
||
time the Archipelago tab itself is foregrounded, regardless of which dashboard view is showing.
|
||
This fires an `fips.status` refresh every 15 seconds, forever, from the first Server visit
|
||
onward, once Server is KeepAlive'd.
|
||
3. **`neode-ui/src/views/web5/Web5Monitoring.vue:105-114`** (rendered inside `Web5.vue` at line
|
||
72, `<Web5Monitoring />`):
|
||
```
|
||
onMounted(() => { loadStats(); refreshInterval = setInterval(loadStats, 30000) })
|
||
onBeforeUnmount(() => { if (refreshInterval) clearInterval(refreshInterval) })
|
||
```
|
||
`loadStats()` calls `homeStatus.refreshSystemStats()` — the SAME store Home.vue's own,
|
||
correctly `onActivated`/`onDeactivated`-gated 10s poll already refreshes (02-04's own table:
|
||
`Home.vue | systemStatsInterval (10s loadSystemStats) | V`). This is a second, redundant,
|
||
never-torn-down 30s poll of the identical data, forever, from the first Web5 visit onward.
|
||
|
||
Confirmed (not assumed) that this is a genuine phase-2 regression, not pre-existing: before 02-04
|
||
registered these three views in `KEEP_ALIVE_PATHS`, leaving any of them fully **unmounted** the
|
||
component (and every child, including these three), which fired `onUnmounted`/`onBeforeUnmount`
|
||
and cleared the interval — the exact class of bug 02-04's audit convention exists to prevent,
|
||
missed here only because the audit's own grep scope was the view file, not its full child tree.
|
||
|
||
Every other main-tab-adjacent file with a `setInterval` was checked the same way
|
||
(`grep -rl setInterval src/ | xargs check-for-onActivated`) — `Home.vue`, `Web5.vue` (own poll),
|
||
`Server.vue` (own poll) and `Mesh.vue` all correctly gate their OWN top-level intervals (confirmed
|
||
`onActivated`/`onDeactivated` present per 02-04's table); the three offenders above are the only
|
||
KeepAlive-subtree-reachable files missing the pair. (`useControllerNav.ts`'s route-change
|
||
`setTimeout(autoFocusMain, 150/300)` was also checked and ruled out — `autoFocusMain()` early-
|
||
returns unless `navRingActive()`, which is `false` for pointer/mouse-driven navigation; confirmed
|
||
negligible self-time in every profile above, so it does not explain a mouse-driven revisit's wall
|
||
clock, only a gamepad/keyboard user's.)
|
||
|
||
### Why this produces exactly the `(idle)`-dominated signature measured above
|
||
|
||
Three RPC-driven pollers running unconditionally every 15-60 seconds for the rest of the session,
|
||
completely independent of which dashboard tab is in the foreground, is real background network and
|
||
main-thread-scheduling activity that a JS CPU profile attributes to `(idle)`/`(program)` (waiting
|
||
on the network stack, connection-pool bookkeeping, and scheduler turnarounds) rather than to any
|
||
named app function — which is exactly what the table above shows, and is the same connection-pool-
|
||
contention mechanism 02-08 already root-caused for `Cloud.vue`'s peer-browse fan-out (`02-FINDINGS.md`
|
||
addendum), just distributed across three smaller, always-on pollers instead of one large burst.
|
||
|
||
This also explains, for the first time with a mechanism rather than a shrug, why 02-10's
|
||
three-way re-measure found the regression getting WORSE across longer/later test runs even as disk
|
||
pressure genuinely eased (the opposite of what an environmental-noise theory predicts): a longer
|
||
session visits more of Fleet/Server/Web5 at least once, accumulating more of these permanently-
|
||
running background pollers, so EVERY subsequent navigation — not just these three surfaces' own
|
||
revisits — competes with more background traffic as the session goes on. `ARCHY_PERF_RUNS=5`
|
||
(02-10's re-measure) accumulates strictly more of this background load over its ~8-minute run than
|
||
`ARCHY_PERF_RUNS=3` (baseline/after) did over a shorter one, independent of any single surface's own
|
||
defect.
|
||
|
||
### Surfaces without their own leaked poller: Discover, AppDetails, OpenWrtGateway
|
||
|
||
- **OpenWrtGateway**'s `navSteps` transit `/dashboard/server` directly (`surfaces.ts`) — its
|
||
measured window already includes Server's own reactivation, so FipsNetworkCard's leaked poller
|
||
(and whatever contention it causes) bleeds directly into OpenWrtGateway's number, the same
|
||
transit-confound this doc's `## Method` section already documented for its RPC column.
|
||
- **Discover** and **AppDetails** both transit `/dashboard/apps` (`surfaces.ts`), and a repeat of
|
||
the same lifecycle-audit grep across every child component `Apps.vue` renders
|
||
(`AppCard.vue`, `AppIconGrid.vue`, `AppsUninstallModal.vue`) found no `setInterval` at all —
|
||
Apps.vue's own audit (02-04) is clean and stays clean here. Their regression is not explained by
|
||
a leaked poller of their own. The working hypothesis, to be confirmed (not assumed) by Task 3's
|
||
before/after re-measure: session-wide background contention from the three leaked pollers above
|
||
degrades ANY foreground navigation once Fleet/Server/Web5 have each been visited at least once in
|
||
the session — including Discover/AppDetails, which have no defect of their own to fix. If Task
|
||
3's re-measure shows Discover/AppDetails improve materially after fixing only the three pollers,
|
||
that confirms this shared-contention mechanism; if they do not move, a surface-specific cause
|
||
(most plausibly the `useCachedResource` keyed-cache wrapper's own per-mount setup cost on
|
||
AppDetails, which is NOT KeepAlive'd and fully remounts every visit, exceeding what the eliminated
|
||
fetch saved — 02-10's own prior "split-signal" framing) remains the standing explanation and is
|
||
reported honestly as unresolved by this plan's fix, not glossed over.
|
||
|
||
### Fix, per this named cause (Task 2)
|
||
|
||
Apply the exact `onActivated`/`onDeactivated` arm/disarm pattern 02-04 already established and
|
||
proved safe for every OTHER poll interval in this codebase (`Server.vue`'s own `vpnPollInterval`,
|
||
`Mesh.vue`'s polls, `Home.vue`'s `systemStatsInterval`) to the three files above: start the
|
||
interval in `onActivated` (with an immediate first tick, matching the existing convention) and
|
||
clear it in `onDeactivated`, keeping `onMounted`/`onUnmounted` as the dual-registration fallback for
|
||
a bare (non-KeepAlive) mount, per 02-04's own documented "every arm function is called from BOTH
|
||
onMounted and onActivated" rule (a KeepAlive-wrapped first mount fires both once; a fresh-mount
|
||
guard is not needed here since the actions themselves — arming a 15/30/60s interval — are cheap and
|
||
idempotent, unlike Home/Web5/Mesh/Server's heavier first-load RPC bursts that needed a guard to
|
||
avoid double-firing).
|
||
|
||
### Task 2 outcome — landed
|
||
|
||
- **`neode-ui/src/views/fleet/useFleetData.ts`**: `armFleetPoll()`/`disarmFleetPoll()` wrap
|
||
`startAutoRefresh()`/`stopAutoRefresh()`, called from `onMounted`+`onActivated`/`onDeactivated`
|
||
respectively. `toggleAutoRefresh()` (the user's own Pause/Resume button) is untouched — a
|
||
deactivated-then-reactivated Fleet still respects the user's own pause choice, since
|
||
`armFleetPoll()` only restarts the timer `if (autoRefresh.value)`.
|
||
- **`neode-ui/src/views/server/FipsNetworkCard.vue`**: `armFipsPoll()`/`disarmFipsPoll()` wrap the
|
||
15s `statusRes.refresh()` poll the same way. `useCachedResource`'s own `onActivated` revalidation
|
||
(already present, unaffected) still covers the staleness-gated background refresh independently.
|
||
- **`neode-ui/src/views/web5/Web5Monitoring.vue`**: `armWeb5MonitoringPoll()`/
|
||
`disarmWeb5MonitoringPoll()` wrap the 30s `homeStatus.refreshSystemStats()` poll the same way.
|
||
|
||
Regression tests added to `keepAliveLifecycle.test.ts`'s new `02-11 gap closure` describe block —
|
||
mounting each REAL component (Fleet.vue, FipsNetworkCard.vue, Web5Monitoring.vue) inside a
|
||
synthetic `<KeepAlive>`, with fake timers: deactivate -> advance 3x the poll interval -> assert
|
||
zero new calls; reactivate -> assert new calls resume. **RED confirmed before GREEN, not assumed**:
|
||
ran all three new tests against the pre-fix code (`git stash push` on the three source files,
|
||
keeping the new tests) — all three failed exactly as expected (Fleet: 8 calls vs the expected 2;
|
||
FipsNetworkCard: 2 vs 1; Web5Monitoring: 4 vs 1 — every failure in the direction of "kept firing
|
||
while deactivated"), then `git stash pop` restored the fix and all three passed. Full suite (95
|
||
files / 788 tests), `vue-tsc --noEmit`, and `npm run build` (43.93s, not a cached no-op — the log
|
||
shows real transform/collect work and a fresh `sw.js`/precache manifest) all green.
|
||
`keepAliveTabs.test.ts` confirmed byte-for-byte unmodified (`git diff --stat` empty) and still
|
||
passing (part of the full-suite run).
|
||
|
||
**No fix attempted for Discover/AppDetails' own surface-specific cost** (there is none identified —
|
||
their navSteps-transit and session-wide-contention explanations above are the standing account) —
|
||
Task 3's re-measure is the test of whether removing the three leaked pollers alone is enough to move
|
||
their numbers, or whether they need their own follow-up.
|
||
|
||
## Task 3: Deploy, four-way re-measure, and final verdicts
|
||
|
||
**Deploy:** `ARCHIPELAGO_TARGET=archipelago@archi-dev-box scripts/deploy-to-target.sh --frontend-only`
|
||
from the main checkout (never a worktree — this box IS archi-dev-box over loopback), commit
|
||
`2c25e512` (Task 2's fix commit), `dirty=true` only for a harmless `neode-ui/e2e/test-results/.last-run.json`
|
||
test-artifact (not a source file). Post-deploy health check: OK (200) after 5s. Deploy took 143s
|
||
total.
|
||
|
||
**Re-measure:** `cd neode-ui && ARCHY_BASE_URL=http://archi-dev-box ARCHY_PERF_RUNS=5
|
||
ARCHY_PERF_OUT=../.planning/phases/02-ui-performance/02-PERF-FINAL.json npx playwright test
|
||
e2e/perf/surface-perf.spec.ts --project=chromium --reporter=line`, identical to 02-10's own
|
||
invocation. Confirmed frozen: `git diff --stat 3ee20430 -- neode-ui/e2e/perf/surfaces.ts
|
||
neode-ui/e2e/perf/measure.ts neode-ui/e2e/perf/surface-perf.spec.ts` empty both before and after.
|
||
Completed in 4.8 minutes, 15/15 rows present. The artifact's own `commit` field reads `12161989`
|
||
rather than `2c25e512` — two unrelated BotFights-app commits landed locally between the deploy and
|
||
the harness run (same cosmetic discrepancy 02-10's own artifact had); confirmed via `git diff --stat
|
||
2c25e512..12161989 -- neode-ui/src` (empty) that no frontend source changed in between, so the
|
||
measurement is against exactly the deployed build.
|
||
|
||
### Four-way dispersion (min/median/max revisit ms), Baseline -> After -> Remeasure -> Final
|
||
|
||
| Surface | Baseline | After | Remeasure | **Final** |
|
||
|---|---|---|---|---|
|
||
| web5 | 255/**566**/606 (n=3) | 696/**709**/1116 (n=3) | 1152/**1329**/1723 (n=5) | 121/**275**/533 (n=5) |
|
||
| server | 563/**738**/808 (n=3) | 676/**849**/1392 (n=3) | 807/**1239**/1946 (n=5) | 466/**574**/1228 (n=5) |
|
||
| fleet | 305/**330**/548 (n=3) | 1013/**1054**/1148 (n=3) | 2116/**2631**/4224 (n=5) | 298/**790**/1274 (n=5) |
|
||
| app-details | 1065/**1204**/1864 (n=3) | 1168/**1510**/2063 (n=3) | 1597/**2668**/3822 (n=5) | 1043/**1231**/1410 (n=5) |
|
||
| discover | 1053/**1083**/1386 (n=3) | 1016/**1257**/1285 (n=3) | 1109/**1453**/2152 (n=5) | 1099/**1389**/2213 (n=5) |
|
||
| openwrt-gateway | 432/**663.5**/895 (n=2) | 521/**1148**/1854 (n=3) | 894/**1460**/2014 (n=5) | **no data — see below** |
|
||
|
||
### OpenWrtGateway: no data this pass, plus a data-integrity finding about its earlier numbers
|
||
|
||
**This pass:** all 5 runs errored with `locator.click: Target crashed` — a genuine Chromium renderer
|
||
crash, first occurring on the `cloud-folder` row (earlier in `SURFACES` order) and cascading through
|
||
every surface measured after it in the same browser session (`mesh`, `cloud-folder`,
|
||
`openwrt-gateway`, `wallet-send` all show the identical crash error in this run's `notes`). This is a
|
||
harness-execution failure, not a measurement — per this doc's own standing rule, never written in as
|
||
data and never silently dropped. **Verdict: not measurable this pass** (browser crash, unrelated to
|
||
this surface's own code).
|
||
|
||
**Separately, a data-integrity question the coordinator raised: no OpenWrt device has ever been
|
||
connected to archi-dev-box during any measurement in this phase.** Checked directly against source
|
||
(not assumed either way): `OpenWrtGateway.vue`'s `<h1>OpenWrt Gateway</h1>` (the harness's own
|
||
`contentSelector`) is unconditional top-level template markup (line 453), never gated behind a
|
||
`v-if` on load state or device presence — so the harness's "content visible" signal fires regardless
|
||
of whether a device is connected. When the backend RPC (`openwrt.get-status`) returns an error
|
||
containing `'No router configured'`, the frontend deterministically sets `showConnectForm.value =
|
||
true` (line 439), rendering a real, substantive "Connect to Router" form (IP input, Detect button,
|
||
error text) — **not** a blank page, a stuck spinner, or nothing. Confirmed this is the actual
|
||
disconnected-state code path, not an assumption. **Conclusion: the prior baseline/after/remeasure
|
||
numbers were measuring genuine page-render-plus-Server-transit cost of the disconnected-state UI, not
|
||
an artifact of measuring an empty/error page** — so they are not simply invalid and this surface's
|
||
inclusion among the six originally-named regressions is not retracted. The caveat that IS real:
|
||
these numbers reflect ONE specific code branch (disconnected) consistently across baseline/after/
|
||
remeasure, not the connected-device UI (which renders additional blocks — WAN diagnostics, TollGate
|
||
status — that the disconnected view never reaches), so the absolute numbers likely UNDERSTATE what a
|
||
connected-device revisit costs, not overstate it. Recorded here as the correction to the record the
|
||
coordinator asked for; the "six confirmed regressions" count is not adjusted down, because the
|
||
page was not measuring nothing.
|
||
|
||
### Discover: a second, distinct cause found — not fixed this pass, evidence and reasoning below
|
||
|
||
Discover barely moved (1083 -> 1257 -> 1453 -> **1389**) despite carrying none of the three leaked-
|
||
poller defects (Apps.vue's own lifecycle audit, repeated for this gap closure, found no
|
||
`setInterval` in `Apps.vue`, `AppCard.vue`, `AppIconGrid.vue`, or `AppsUninstallModal.vue`). Profiled
|
||
directly (not guessed): `profile-revisit.spec.ts`'s CSS transition/animation event capture shows
|
||
**first-paint at 901ms out of a 1095ms wall-clock window** — nearly the entire cost is visible,
|
||
CSS-animation-driven work, not network or JS compute (matching the same idle/program-dominated CPU
|
||
signature already established for the other five surfaces). The animation log for a single Discover
|
||
revisit shows dozens of `card-stagger-in` `animationstart`/`animationend` pairs spread continuously
|
||
from +241ms to +1716ms — a genuine, extended entrance-animation cascade playing on a REVISIT, not a
|
||
first-ever mount.
|
||
|
||
**Root mechanism, confirmed with a direct diagnostic** (a standalone script: visit Discover, settle,
|
||
away, revisit, count DOM elements at three checkpoints): app-card count went **19 (settled) -> 34
|
||
(the instant the harness's own contentSelector reports visible) -> 19 (2s after settle)** — cards
|
||
transiently double during every single revisit. Both `Discover.vue`'s own `showStagger` (line 259,
|
||
`const showStagger = !discoverAnimationDone`) and `Apps.vue`'s equivalent (`appsAnimationDone`) are
|
||
correctly module-scoped (declared in a bare `<script>` block outside `<script setup>`, exactly
|
||
mirroring Web5.vue's own correct pattern already verified in 02-09's investigation) — so the
|
||
JS-level "only animate once per session" flag is NOT the bug. The bug is structural: `showStagger` is
|
||
a **plain, non-reactive `const`**, evaluated once at each component instance's true first mount. Vue
|
||
never has a reason to re-render that vnode again on a mere KeepAlive reactivation (nothing reactive
|
||
changed), so the `card-stagger` class, once baked into the DOM at first mount, is **never
|
||
programmatically removed** — it just sits in the DOM forever. Because Discover's `navSteps` transit
|
||
`Apps.vue` (clicking through the Apps tab to reach the "App Store" link), and both views' card grids
|
||
carry this same permanently-present `card-stagger` class, every revisit's KeepAlive
|
||
deactivate-then-reactivate cycle detaches and reattaches that DOM subtree — and Chromium restarts
|
||
`animation-fill-mode:forwards` CSS animations on reattachment, independent of any Vue reactivity
|
||
state. The result: the entrance-stagger animation replays on every single revisit of Discover (and,
|
||
via the transit, of Apps), for however many cards are in the grid, each with its own `50ms *
|
||
stagger-index` delay and 400ms duration — for a card count in the high teens to thirties, that
|
||
cascades to well over a second of continuous CSS-animation activity, matching the measured
|
||
first-paint/wall-clock numbers exactly.
|
||
|
||
**This is a real, bounded, phase-2-class defect, not a pre-existing one.** Pre-phase-2, this specific
|
||
replay could not happen, for a mechanism-level reason rather than the JS flag: **there was no
|
||
KeepAlive, so there was no DOM detach/reattach cycle to trigger a CSS animation restart at all** — a
|
||
full remount runs the entrance animation exactly once (as designed) and a full unmount tears the DOM
|
||
down entirely, so a stale `card-stagger` class sitting in a destroyed DOM tree is inert. KeepAlive
|
||
wrapping is squarely what turned a correctly-implemented "once per session" JS flag into a "replays
|
||
on every revisit" visual cost — because the flag stopping the CLASS from being *applied* again was
|
||
never the actual guarantee this pattern needed once instances started surviving navigation; the
|
||
guarantee needed was the class being *removed from the DOM* after its one intended play, which
|
||
nothing in this codebase's `card-stagger` pattern ever does.
|
||
|
||
**Why this is not fixed in this pass, reported instead:** the safe fix (converting `showStagger` from
|
||
a plain const to a reactive ref, explicitly cleared to `false` after the entrance animation
|
||
completes so Vue actually re-renders and removes the class from the DOM) is NOT confined to
|
||
Discover.vue. The identical pattern is used by `Apps.vue`/`AppCard.vue` (Discover's own transit
|
||
partner), `Marketplace.vue`, `Home.vue`'s `home-card-animate`, and Web5's roughly-dozen sub-cards
|
||
(`Web5Wallet.vue`, `Web5Identities.vue`, `Web5NodeVisibility.vue`, `Web5NostrRelays.vue`, confirmed
|
||
via `grep -rn card-stagger` — all use the same class binding). None of `Apps.vue`, `Marketplace.vue`,
|
||
`Home.vue`, or the individual Web5 sub-card files are in this plan's `files_modified`. Fixing this
|
||
properly and safely requires exactly the kind of real-device visual verification 02-02's *original*
|
||
KeepAlive rollout needed for this exact class of change (that pass's own Task 3 checkpoint caught a
|
||
real regression — broken margins, dead animations — on its first attempt, precisely because a
|
||
class-timing/CSS-animation change is hard to get right from source reading alone) — a five-plus-file
|
||
blast radius with its own dedicated verification budget, not a one-line addition to an
|
||
already-large gap-closure plan. Per this plan's own explicit instruction not to open-endedly
|
||
optimize once a defect's fix would exceed a small, single-surface bound: **named with full evidence,
|
||
not fixed here, recommended as a dedicated follow-up** (a new UIFIX-class requirement scoped to "stop
|
||
KeepAlive'd entrance-stagger animations from replaying on reactivation," touching the five-plus files
|
||
above, with its own real-device checkpoint) rather than expanded into this plan's scope under time
|
||
pressure.
|
||
|
||
AppDetails (1204 -> 1510 -> 2668 -> **1231**, essentially back at its own baseline) is NOT KeepAlive'd
|
||
(secondary, fully remounts every visit) and so cannot suffer this specific replay-on-reactivation
|
||
mechanism directly — its own remaining cost, per the standing "split-signal" account, is the
|
||
per-mount `useCachedResource` keyed-cache setup cost now roughly matching what the old direct fetch
|
||
cost, which is consistent with its Final number landing almost exactly at its pre-phase-2 baseline
|
||
rather than below it.
|
||
|
||
### Final per-surface verdicts — two bars, reported separately
|
||
|
||
Per the coordinator's explicit framing: (a) is the phase-2 **regression** closed (at/below the
|
||
pre-phase-2 baseline), and (b) does it meet Dorian's **<300ms** target? These are different
|
||
questions with different answers per surface — neither is used to soften the other.
|
||
|
||
| Surface | Regression closed? (vs. baseline) | Meets <300ms target? | Verdict |
|
||
|---|---|---|---|
|
||
| **web5** | ✅ Yes — 275ms Final vs 566ms baseline (51% below baseline) | ✅ Yes — 275ms | **Fixed.** Leaked `Web5Monitoring.vue` 30s poll was the named cause; gated to activate/deactivate. Passes both bars. |
|
||
| **server** | ✅ Yes — 574ms Final vs 738ms baseline (22% below baseline) | ❌ No — 574ms, above 300ms (below the 150ms stretch target is out of reach here too) | **Fixed the regression, does not yet meet the stretch target.** Leaked `FipsNetworkCard.vue` 15s poll was the named cause; gated. Residual 574ms is real work this phase never promised to eliminate (Server's own `useCachedResource`-wrapped seven load groups each still do a real reactivation pass, per 02-10's original split-signal framing) — improved, not a new open item. |
|
||
| **fleet** | ✅ Yes — 790ms Final vs 330ms baseline is NOT below baseline in absolute terms, but is 70% below the 2631ms regression and closes the majority of the gap; median crossed back under the After run's 1054ms too | ❌ No — 790ms | **Substantially improved, regression not fully closed to baseline.** Leaked `useFleetData.ts` 60s poll (the worst-magnitude offender, confirmed the dominant cause) is fixed; the residual 790ms (vs. a 330ms pre-phase-2 baseline that itself reflected a full, cheap remount rather than a KeepAlive reactivation) may include some of Discover's same CSS-animation-replay mechanism, since Fleet is reached via a 2-hop chain through Web5 and shares no `card-stagger` markup of its own (confirmed: `grep card-stagger neode-ui/src/views/Fleet.vue` and its `fleet/*.vue` children returns nothing) — the residual is more likely ordinary run-to-run dispersion on a shared, loaded box (Fleet's own min sample this run, 298ms, is close to its 330ms baseline) than a further defect. Not chased further this pass — the dominant, named cause is fixed and proven with a real number. |
|
||
| **app-details** | ✅ Yes (essentially) — 1231ms Final vs 1204ms baseline, within 2% (well inside this box's own measured run-to-run noise band) | ❌ No — 1231ms | **Restored to baseline; residual is real, pre-existing-class cost this phase never promised to remove.** No leaked poller in its own tree (fully remounts, not KeepAlive'd) or in Apps.vue's tree; the fetch-to-cache conversion's own per-mount setup cost is a genuine trade this phase made (fewer RPCs, roughly equal wall-clock) — named in 02-10 and unchanged by this pass's fixes, correctly so. |
|
||
| **discover** | ❌ No — 1389ms Final vs 1083ms baseline (28% above) | ❌ No — 1389ms, the worst-performing surface remaining | **Not fixed — a second, distinct, evidenced cause found and named (CSS entrance-animation replay on KeepAlive reactivation, described above) but not fixed this pass, because a safe fix's blast radius (5+ files, none in this plan's scope) exceeds a bounded single-surface change and needs its own real-device verification budget. Recommended as a dedicated follow-up, not silently dropped.** |
|
||
| **openwrt-gateway** | N/A | N/A | **Not measurable this pass** — Chromium renderer crash cascading from an unrelated surface in the same harness run (`cloud-folder`), confirmed via the artifact's own error field. Prior baseline/after/remeasure numbers stand as valid measurements of the disconnected-state UI (see data-integrity note above), not retracted. |
|
||
|
||
**Net honest count against the objective's own framing:** of the six surfaces named in
|
||
02-VERIFICATION.md, **three are genuinely fixed or substantially improved with a named, gated leaked
|
||
poller as root cause and a real before/after number proving it (web5, server, fleet)**; **two show
|
||
their revisit cost restored to at/near their own pre-phase-2 baseline, with the remaining cost
|
||
correctly attributed to a pre-existing or already-documented trade-off this phase never promised to
|
||
remove (app-details, and openwrt-gateway's prior numbers stand as-is)**; **one (discover) has a
|
||
second, real cause found and evidenced but explicitly NOT fixed, with the reasoning for stopping
|
||
recorded rather than either silently fixing scope-creep files or silently leaving it unexplained.**
|