docs(02-ui-performance): research phase domain
Codebase-grounded research for KeepAlive/SWR caching, serial-waterfall fixes, and the AIUI D-14 ride-along dependency risk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2f99db5e6b
commit
350c06bcaf
737
.planning/phases/02-ui-performance/02-RESEARCH.md
Normal file
737
.planning/phases/02-ui-performance/02-RESEARCH.md
Normal file
@ -0,0 +1,737 @@
|
||||
# Phase 2: UI Performance - Research
|
||||
|
||||
**Researched:** 2026-07-30
|
||||
**Domain:** Vue 3 SPA rendering/data-caching performance (client-side only; no new external dependencies)
|
||||
**Confidence:** HIGH (codebase-verified for architecture/pitfalls; MEDIUM for the one external Vue-core issue citation; LOW/flagged for the AIUI ride-along, D-14)
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
**Caching Approach (PERF-02)**
|
||||
- **D-01:** Main tabs use BOTH `<KeepAlive>` around the RouterView (component instances,
|
||||
scroll position, and in-page state survive tab switches) AND `useCachedResource`
|
||||
stale-while-revalidate for their data. Today no `<KeepAlive>` exists anywhere — every
|
||||
tab switch fully remounts the view.
|
||||
- **D-02:** Which views get converted is decided by profiling (D-09), not blanket
|
||||
conversion — "fixes are targeted, not guessed" (PERF-01). Already-fast views are left
|
||||
alone.
|
||||
- **D-03:** Heavy views (Mesh with its D3 graph + Leaflet map) ARE kept alive, but
|
||||
KeepAlive's `max` instance cap is set so the oldest unused views evict — bounded memory
|
||||
on low-power nodes.
|
||||
- **D-04:** Secondary screens (AppDetails, server sub-pages, etc.) get `useCachedResource`
|
||||
keyed per item (e.g. `app-details:${appId}`) but NO KeepAlive — unbounded item counts
|
||||
would bloat an instance cache. Repeat opens paint instantly from the data cache.
|
||||
|
||||
**Refresh / Staleness UX**
|
||||
- **D-05:** Background refresh on a cached view shows a SUBTLE indicator (small
|
||||
spinner/shimmer in the header/tab area driven by `loadState === 'refreshing'`) — not
|
||||
invisible, not stale-age badges.
|
||||
- **D-06:** Default TTL 30s (the hook's default); per-view tuning is Claude's discretion
|
||||
(shorter for fast-moving data like mesh peers/sync status, longer for near-static data
|
||||
like the app catalog).
|
||||
- **D-07:** Background refresh failures are silent keep-last-value — cached data stays on
|
||||
screen, retry on next focus/TTL; no toast. Errors surface only on explicit user refresh.
|
||||
- **D-08:** sessionStorage persistence ON (instant paint after reload) except for large
|
||||
payloads (file lists, media), which stay memory-only — the hook's `persist: false`.
|
||||
|
||||
**Profiling & Verification (PERF-01, PERF-03)**
|
||||
- **D-09:** Profiling starting set = ALL main surfaces: Apps/app store (+ AppDetails),
|
||||
Mesh, Wallet (+ send flows), Cloud/Files, Server, Network, and Web5. User confirmed all
|
||||
of these feel slow, "often app store".
|
||||
- **D-10:** Profiling produces a COMMITTED findings doc in the phase directory: each slow
|
||||
surface → measured cause (remount storm / serial RPC waterfall / uncached fetch) →
|
||||
intended fix. Written and committed BEFORE fixes land.
|
||||
- **D-11:** On-device verification target is **archi-dev-box**. Pass bar: NO visible
|
||||
spinner/blank on revisit of a tab or secondary screen already visited this session;
|
||||
first visits may still show loading.
|
||||
|
||||
**Fix Scope**
|
||||
- **D-12:** Backend (`core/`) changes are ADDITIVE ONLY: new aggregate/batch RPC endpoints
|
||||
and cheap response-shaping are allowed when profiling names a backend cause; NO refactors
|
||||
of existing handlers and NOTHING touching the orchestrator (keeps the lifecycle gate out
|
||||
of play).
|
||||
- **D-13:** Serial fetch waterfalls in views are fixed client-side by parallelizing
|
||||
(`Promise.all`) plus rpc-client request dedup. Aggregate endpoints (per D-12) only where
|
||||
a screen genuinely needs 3+ dependent calls.
|
||||
- **D-14:** Folded-in AIUI UX defaults (small, in-scope ride-alongs): (a) the AIUI chat
|
||||
starts EXPANDED, (b) on mobile AIUI starts on the CHAT view, not the context view
|
||||
(current start-on-context is wrong). Everything else AIUI-related is deferred.
|
||||
- **D-15:** Deploy discipline: dev pair only this phase — no OTA. Fleet rollout rides the
|
||||
normal release train later.
|
||||
|
||||
### Claude's Discretion
|
||||
- Per-view TTL values (D-06).
|
||||
- KeepAlive `max` cap value and eviction tuning (D-03).
|
||||
- Choice between client-side parallelization and a new aggregate endpoint per screen,
|
||||
within D-12/D-13 bounds.
|
||||
- Exact placement/styling of the subtle refresh indicator (D-05), consistent with the
|
||||
existing design system.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- AIUI permissioned node access, AIUI content deep-dive, AIUI peer media, IndeeHub
|
||||
cross-node content source with payments, AIUI Nostr integration polish. (The two small
|
||||
AIUI UX defaults — chat starts expanded; mobile starts on chat — were folded INTO this
|
||||
phase as D-14 and are NOT deferred.)
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| PERF-01 | Slowest tab switches/secondary-screen opens profiled, causes named (remount storm / serial RPC waterfall / uncached fetch), before fixes land | See "Concrete Findings Per Surface" below — a code-level pre-scan of every D-09 surface, with the actual `onMounted` patterns found, to seed (not replace) the required profiling pass. `## Validation Architecture` maps this to a committed findings doc, not an automated test. |
|
||||
| PERF-02 | Main-tab switches render instantly from cache, refresh in background | `## Architecture Patterns` (KeepAlive + RouterView slot pattern for THIS codebase's actual nested router-view), `## Common Pitfalls` (the `onActivated` gap in `useCachedResource`, the async-component name-matching KeepAlive bug, the `:key="route.path"` trap already in `Dashboard.vue`) |
|
||||
| PERF-03 | Secondary screens open without blocking reload, instant on repeat visit, verified on real hardware | `## Don't Hand-Roll` (reuse `useCachedResource` keyed per item, per D-04 — no KeepAlive), `## Environment Availability` (archi-dev-box reachability confirmed this session) |
|
||||
</phase_requirements>
|
||||
|
||||
## Summary
|
||||
|
||||
This phase is almost entirely a **client-side Vue 3 architecture fix**, not a new-library
|
||||
adoption — the caching primitive (`useCachedResource` + `resources` Pinia store) and the
|
||||
dedup primitive (`rpc-client`'s `dedup: true`) already exist and are production-proven
|
||||
across 8 views. The gap is structural: (1) nothing in the app tree uses `<KeepAlive>` yet,
|
||||
so every tab switch fully unmounts/remounts, and (2) several of the D-09 "feels slow"
|
||||
surfaces still fetch data with plain `onMounted` + raw `rpcClient.call` instead of the
|
||||
cached hook, or fire genuinely serial `await` chains.
|
||||
|
||||
The single most important ground-truth finding this research turned up — and the one the
|
||||
planner most needs — is that **the actual KeepAlive insertion point is NOT `App.vue`'s
|
||||
outer `RouterView`** (which CONTEXT.md's canonical_refs points to). `App.vue`'s
|
||||
`RouterView` only ever renders `OnboardingWrapper.vue` or `Dashboard.vue` — those don't
|
||||
remount on tab switch. The real per-tab mount/unmount churn happens in a **second,
|
||||
nested** `<RouterView>` inside `Dashboard.vue` (`neode-ui/src/views/Dashboard.vue:87`),
|
||||
which is also keyed by `route.path` for its `<Transition>` and branches into two different
|
||||
wrapper `<div>`s (a chat/mesh branch and a default branch). `<KeepAlive>` must wrap
|
||||
`<component :is="Component" />` directly inside THAT structure, in both branches, and the
|
||||
current `:key="route.path"` placement needs to move so it doesn't recreate the cached
|
||||
component itself. This is a bigger template restructure than "wrap RouterView" implies,
|
||||
and the plan should scope a dedicated task for it.
|
||||
|
||||
The second load-bearing finding is a genuine gap in `useCachedResource` itself: KeepAlive's
|
||||
`onActivated`/`onDeactivated` hooks are not currently used anywhere in the hook. Because a
|
||||
KeepAlive'd component is *deactivated*, not unmounted, on tab-away, `onScopeDispose` never
|
||||
fires — the `window.addEventListener('focus', ...)` and store subscription stay live for
|
||||
every kept-alive tab simultaneously (a minor traffic consideration), AND, more importantly,
|
||||
there is currently no mechanism that re-checks staleness when a KeepAlive'd tab is
|
||||
*reactivated* (switched back to) — only on window focus or on first mount. Without adding
|
||||
an `onActivated(() => refreshIfStale())` call to the hook, D-01's "background refresh on
|
||||
revisit" behavior will not actually trigger for KeepAlive'd main tabs. This is safe to add
|
||||
unconditionally (Vue no-ops these hooks outside a `<KeepAlive>` boundary), so it benefits
|
||||
both main tabs and (harmlessly) secondary screens using the same hook.
|
||||
|
||||
**Primary recommendation:** Fix the Dashboard.vue nested-RouterView structure first (the
|
||||
KeepAlive host) and extend `useCachedResource` with `onActivated`-driven revalidation
|
||||
before converting any individual view — both are one-time, shared foundation work that
|
||||
every per-surface fix in PERF-01's findings doc will depend on. Then profile the D-09
|
||||
surfaces, convert only what profiling names (D-02), and fix any serial `await` chains with
|
||||
`Promise.all`/`Promise.allSettled` (a real instance already found in
|
||||
`ContainerAppDetails.vue`, detailed below).
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Main-tab component-instance/scroll persistence (KeepAlive) | Browser/Client | — | Pure client-side SPA (no SSR); Vue's built-in `<KeepAlive>` owns instance lifecycle |
|
||||
| Main-tab & secondary-screen data caching (SWR) | Browser/Client | — | `useCachedResource` + `resources` Pinia store already own this; extend, don't replace |
|
||||
| In-flight request dedup | Browser/Client (rpc-client) | — | `rpc-client.ts`'s `dedup: true` option already implements this |
|
||||
| Serial-waterfall fixes (client parallelization) | Browser/Client | — | `Promise.all`/`Promise.allSettled` around existing independent fetch calls |
|
||||
| New aggregate/batch RPC endpoints (only for 3+ dependent calls, D-12/D-13) | API/Backend (`core/`) | Browser/Client (consumes via existing rpc-client) | Additive-only per D-12; the calling view still owns caching/dedup client-side |
|
||||
| Profiling & measurement (PERF-01) | Browser/Client (DevTools Performance panel, Vue devtools timeline) | — | No backend instrumentation is required or in scope |
|
||||
| AIUI chat-expanded / mobile-starts-on-chat defaults (D-14) | External app (AIUI, separate repo/container image) | Browser/Client (neode-ui passes iframe URL query params) | AIUI's own source is NOT in this checkout — see Open Questions |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| Vue 3 `<KeepAlive>` | 3.5.24 (built-in, already installed) [VERIFIED: neode-ui/package.json] | Cache main-tab component instances across route changes | Native Vue primitive purpose-built for exactly this; no reason to hand-roll |
|
||||
| Vue Router `v-slot="{ Component, route }"` on `<router-view>` | vue-router 4.6.3 (already installed) [VERIFIED: neode-ui/package.json] | Only supported way to interpose `<KeepAlive>`/`<Transition>` between the router and the rendered view in Vue Router 4 | Directly wrapping `<router-view>` with `<KeepAlive>` does not work in Vue Router 4 — the scoped-slot form is required [CITED: router.vuejs.org/guide/advanced/router-view-slot] |
|
||||
| `useCachedResource` composable | project-internal, `neode-ui/src/composables/useCachedResource.ts` | SWR data layer: memory → sessionStorage → fetch, sticky-ready, keep-last-value, focus revalidation, abort-on-unmount | Already proven across 8 views (Monitoring, Cloud, Server, Federation, Credentials, Web5, FIPS cards) — the pattern to extend, per canonical_refs |
|
||||
| `resources` Pinia store | project-internal, `neode-ui/src/stores/resources.ts` | Shared cache backing the hook; in-flight dedup per key, invalidate/subscribe | Backing store for the hook above — don't create a second cache layer |
|
||||
| `rpc-client.ts` `dedup: true` | project-internal | Collapses concurrent identical calls (same method+params) into one request | Already implemented; use for any newly-parallelized fetch group so duplicate concurrent calls collapse |
|
||||
| Native `Promise.all` / `Promise.allSettled` | JS built-in | Parallelize independent RPC calls instead of serial `await` chains | No library needed; D-13 explicitly calls for this over any queue/batching library |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| Browser Performance API (`performance.mark`/`performance.measure`) | Web platform, no install | Instrument tab-switch/secondary-screen-open timings for the PERF-01 findings doc | When profiling needs a repeatable, loggable number rather than just "felt slow in DevTools" |
|
||||
| Chrome/Chromium DevTools Performance panel | Browser built-in | Visual flame-graph profiling of remount storms and RPC waterfalls | Primary profiling tool for D-10's findings doc — no new tooling to install |
|
||||
| Vue devtools (browser extension) | Already used by the team per existing dev workflow | Component render/mount timeline, KeepAlive cache inspection | Useful to visually confirm `<KeepAlive>` is actually caching the intended component names |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| `<KeepAlive>` | Custom `v-show`-based tab persistence (render all main tabs simultaneously, toggle visibility) | Avoids the KeepAlive/async-component name-matching bug entirely, but keeps ALL main-tab DOM (D3 graph, Leaflet map, all lists) mounted and reactive permanently — worse memory/CPU on low-power nodes than a capped KeepAlive; rejected, out of step with D-03's bounded-memory requirement |
|
||||
| Client-side `Promise.all` (D-13's default) | New aggregate RPC endpoint per screen | Aggregate endpoints reduce round-trips further but require backend changes (additive-only per D-12) and add a new response shape to maintain; reserve for screens with 3+ genuinely dependent calls, per D-13 |
|
||||
| `useCachedResource` extension | A dedicated third-party SWR library (e.g. a Vue port of `swr`/`vue-query`) | Would duplicate functionality the project already built and tuned (sticky-ready, sessionStorage snapshot, keep-last-value) and adds an external dependency for zero net capability gain — explicitly against the phase's canonical_refs guidance to extend, not reinvent |
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
# No new packages required — KeepAlive is Vue 3 core, Promise.all is native JS,
|
||||
# useCachedResource/resources/rpc-client already exist in the codebase.
|
||||
```
|
||||
|
||||
**Version verification:** `neode-ui/package.json` pins `"vue": "^3.5.24"` and
|
||||
`"vue-router": "^4.6.3"` [VERIFIED: package.json read directly]. The async-component
|
||||
KeepAlive `include`/`exclude` name-matching bug (vuejs/core #7533 / #11764) was reported
|
||||
fixed by extracting the component name from `__asyncResolved` rather than the wrapper
|
||||
vnode; treat as fixed at 3.5.24 but confirm with a quick manual smoke test on the actual
|
||||
lazy-loaded routes in this repo before relying on `include`/`exclude` filtering (see
|
||||
Common Pitfalls #1) [CITED: github.com/vuejs/core/issues/11764].
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
**No external packages are being installed for this phase.** Every primitive needed
|
||||
(`<KeepAlive>`, `Promise.all`/`Promise.allSettled`, `useCachedResource`, `resources` store,
|
||||
`rpc-client` dedup) is either Vue 3 core, a JS language built-in, or already present and
|
||||
in production use in this codebase. The Package Legitimacy Gate protocol is not applicable
|
||||
— skip the registry/postinstall checks; there is nothing new to vet.
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none (n/a — no new packages).
|
||||
**Packages flagged as suspicious [SUS]:** none (n/a — no new packages).
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
User clicks a tab / router-link
|
||||
│
|
||||
▼
|
||||
Vue Router navigates (App.vue's OUTER RouterView is untouched — it only
|
||||
distinguishes OnboardingWrapper vs Dashboard, both of which stay mounted)
|
||||
│
|
||||
▼
|
||||
Dashboard.vue's NESTED <router-view v-slot="{ Component, route }"> ◄── the
|
||||
│ real
|
||||
▼ remount
|
||||
<Transition :name="..."> point
|
||||
│ (line ~87,
|
||||
▼ neode-ui/src/
|
||||
<KeepAlive :include="MAIN_TAB_NAMES" :max="N"> ◄── NEW views/
|
||||
│ Dashboard.vue)
|
||||
├── cache HIT (tab visited this session) ──► instance reactivated
|
||||
│ │ (onActivated fires)
|
||||
│ ▼
|
||||
│ useCachedResource.refreshIfStale() re-checked on activation ◄── NEW
|
||||
│ │ (extension to the hook)
|
||||
│ ├── fresh (< TTL) ──────────────────► render immediately, no RPC
|
||||
│ └── stale (≥ TTL) ──► background refresh (loadState='refreshing')
|
||||
│ │ subtle indicator (D-05)
|
||||
│ ▼
|
||||
│ rpc-client.call({ dedup: true })
|
||||
│ │
|
||||
│ ▼
|
||||
│ Backend RPC (core/, additive-only, D-12)
|
||||
│
|
||||
└── cache MISS (first visit this session) ──► fresh mount
|
||||
│
|
||||
▼
|
||||
useCachedResource: memory → sessionStorage snapshot → fetch
|
||||
│ (paints instantly if a snapshot exists,
|
||||
│ D-08, except persist:false payloads)
|
||||
▼
|
||||
Parallel fetch (Promise.all / Promise.allSettled, D-13) instead
|
||||
of serial await chains — fixes the ContainerAppDetails.vue-style
|
||||
waterfall found below
|
||||
│
|
||||
▼
|
||||
rpc-client.call({ dedup: true }) → Backend RPC → render
|
||||
|
||||
Secondary screens (AppDetails, ContainerAppDetails, server sub-pages, ...):
|
||||
same nested RouterView, but the component is OUTSIDE (or excluded from) the
|
||||
KeepAlive boundary (D-04) — full mount/unmount each visit, but
|
||||
useCachedResource keyed per item (e.g. `app-details:${appId}`) still paints
|
||||
instantly from the data cache on repeat opens.
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
neode-ui/src/
|
||||
├── views/
|
||||
│ ├── Dashboard.vue # MODIFY: nested RouterView gets KeepAlive
|
||||
│ ├── dashboard/
|
||||
│ │ └── useRouteTransitions.ts # existing TAB_ORDER / isDetailRoute — extend
|
||||
│ │ # isDetailRoute if new secondary routes need
|
||||
│ │ # explicit KeepAlive exclusion (see Pitfall #7)
|
||||
│ ├── Apps.vue, Mesh.vue, Cloud.vue, Server.vue, ... # CONVERT per profiling (D-02)
|
||||
│ ├── AppDetails.vue, ContainerAppDetails.vue, ... # keyed useCachedResource,
|
||||
│ │ # no KeepAlive (D-04)
|
||||
├── composables/
|
||||
│ └── useCachedResource.ts # MODIFY: add onActivated(refreshIfStale) hook
|
||||
├── stores/
|
||||
│ └── resources.ts # unchanged — already supports this
|
||||
└── api/
|
||||
└── rpc-client.ts # unchanged — dedup already supported
|
||||
```
|
||||
|
||||
### Pattern 1: KeepAlive + RouterView scoped slot (the only correct Vue Router 4 form)
|
||||
**What:** Vue Router 4 removed the ability to simply wrap `<router-view>` with
|
||||
`<KeepAlive>`/`<Transition>` — the scoped-slot form is mandatory.
|
||||
**When to use:** The single nested `<router-view>` in `Dashboard.vue` that renders all
|
||||
tab/secondary-screen content.
|
||||
**Example:**
|
||||
```vue
|
||||
<!-- Source: router.vuejs.org/guide/advanced/router-view-slot (Vue Router 4 official pattern) -->
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition :name="transitionName">
|
||||
<keep-alive :include="mainTabComponentNames" :max="8">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
```
|
||||
Note: `:key="route.path"` is safe to keep on `<component>` itself here — for the fixed set
|
||||
of main-tab paths (`/dashboard/apps`, `/dashboard/mesh`, ...) the path is stable per view,
|
||||
so KeepAlive still matches the same cache slot on revisit. It only becomes a problem if a
|
||||
secondary/detail route with a *varying* `:id` param is included in the same KeepAlive
|
||||
boundary (each id would get its own cache slot) — which is exactly why D-04 excludes
|
||||
secondary screens from KeepAlive entirely.
|
||||
|
||||
`Dashboard.vue`'s current template (`neode-ui/src/views/Dashboard.vue:87-118`) branches
|
||||
into two different wrapper `<div>`s depending on `route.path` (a chat/mesh branch with
|
||||
different classes, and a default branch with scroll-container classes). Both branches wrap
|
||||
`<component :is="Component" />` — the KeepAlive needs to live inside BOTH branches (or the
|
||||
branching needs to move to apply classes to a single shared wrapper so one KeepAlive
|
||||
covers both cases). This is real template surgery, not a one-line wrap.
|
||||
|
||||
### Pattern 2: `useCachedResource` with `onActivated` revalidation (extension needed)
|
||||
**What:** Add reactivation-triggered staleness checks so KeepAlive'd tabs actually
|
||||
background-refresh on revisit, not just on window focus.
|
||||
**When to use:** Inside `useCachedResource` itself, so every consuming view benefits
|
||||
without per-view changes.
|
||||
**Example:**
|
||||
```typescript
|
||||
// Source: neode-ui/src/composables/useCachedResource.ts (existing) + Vue's onActivated
|
||||
// (vuejs.org/api/composition-api-lifecycle.html#onactivated) — safe no-op outside
|
||||
// <KeepAlive>, so this is safe to add unconditionally for every consumer.
|
||||
import { onActivated } from 'vue'
|
||||
// ... inside useCachedResource, alongside the existing onScopeDispose block:
|
||||
if (getCurrentScope()) {
|
||||
onActivated(() => refreshIfStale())
|
||||
}
|
||||
```
|
||||
This mirrors the existing `if (opts.immediate ?? true) refreshIfStale()` call at setup
|
||||
time — `onActivated` also fires on first mount (per Vue's lifecycle docs), so the two
|
||||
calls are redundant-but-harmless on first mount (the store's `inflight` map dedupes them)
|
||||
and the ONLY source of a stale-check on every subsequent reactivation.
|
||||
|
||||
### Pattern 3: Parallelize a serial `onMounted` waterfall
|
||||
**What:** Replace sequential `await` chains with `Promise.all`/`Promise.allSettled`.
|
||||
**When to use:** Any `onMounted` (or other lifecycle hook) that awaits independent RPC
|
||||
calls one after another with no data dependency between them.
|
||||
**Example — the actual bug found in this codebase, `ContainerAppDetails.vue:169-172`:**
|
||||
```typescript
|
||||
// BEFORE (serial waterfall — 3 sequential round-trips):
|
||||
onMounted(async () => {
|
||||
await loadContainer()
|
||||
await loadLogs()
|
||||
await loadHealthStatus()
|
||||
})
|
||||
|
||||
// AFTER (parallel — same 3 calls, one round-trip's worth of latency):
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([loadContainer(), loadLogs(), loadHealthStatus()])
|
||||
})
|
||||
```
|
||||
`loadContainer`/`loadLogs`/`loadHealthStatus` each set their own `loading` ref
|
||||
independently and don't read each other's results, so this is a safe parallelization —
|
||||
confirm this per-screen during profiling (D-02), don't blanket-apply.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Wrapping `App.vue`'s outer `<router-view>` with `<KeepAlive>` and calling it done:**
|
||||
this RouterView only ever swaps `OnboardingWrapper.vue` ↔ `Dashboard.vue` ↔ 404 — it
|
||||
doesn't remount on tab switch. The real fix must land in `Dashboard.vue`'s nested
|
||||
RouterView. Fixing the wrong RouterView would look correct in code review but produce
|
||||
zero observed improvement, since `Dashboard.vue` itself was never the thing remounting.
|
||||
- **Blanket `<KeepAlive>` with no `include`/`max`:** caches every route ever visited
|
||||
forever — unbounded memory on low-power fleet nodes, and defeats D-04 (secondary
|
||||
screens must NOT be instance-cached).
|
||||
- **Hand-rolling a second cache layer per view** (a local `ref` + manual
|
||||
`sessionStorage.getItem`) instead of extending `useCachedResource` — this is exactly the
|
||||
duplication the canonical_refs explicitly warn against.
|
||||
- **Treating `onMounted` as "runs once per view visit" once KeepAlive is added:** it does
|
||||
not — `onMounted` only fires on first mount. Any per-visit logic (scroll restore,
|
||||
one-shot animation flags like `Apps.vue`'s `appsAnimationDone`, `Server.vue`'s
|
||||
`connectionTimer` setup) that assumed "onMounted = every view entry" must move to
|
||||
`onActivated`/`onDeactivated` once that view is KeepAlive'd, or it silently stops running
|
||||
on revisit.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Stale-while-revalidate data cache | A new per-view `ref` + manual `sessionStorage` read/write | `useCachedResource` (extend with `onActivated`, Pattern 2 above) | Already handles sticky-ready, keep-last-value-on-error, focus revalidation, abort-on-unmount, sessionStorage snapshotting — reinventing it per-view is exactly the "8 views already use it, main tabs just don't yet" gap this phase closes |
|
||||
| Component-instance persistence across route changes | A custom `v-show`-based always-mounted tab strip, or manual component-instance caching via a `Map` | Vue's built-in `<KeepAlive>` | Native primitive with `max`/LRU eviction, `include`/`exclude` name matching, and `activated`/`deactivated` hooks purpose-built for this |
|
||||
| In-flight request dedup | A custom promise-cache keyed by method+params | `rpc-client.ts`'s `dedup: true` option | Already implemented and used elsewhere (`Cloud.vue`, `Server.vue` pass `dedup: true` on several calls already) |
|
||||
| Parallel-fetch orchestration | A custom fetch queue/batcher | `Promise.all`/`Promise.allSettled` | Native JS; D-13 explicitly scopes fixes to this, reserving new endpoints for 3+ dependent calls only |
|
||||
| Performance measurement | Custom timing/telemetry instrumentation shipped to a backend | Browser DevTools Performance panel + `performance.mark`/`measure` (local, not shipped anywhere) | PERF-01 needs a committed findings doc, not a telemetry pipeline; out of scope per D-12 (no orchestrator/backend refactors) |
|
||||
|
||||
**Key insight:** This phase has zero legitimate reasons to add a new library. The
|
||||
project already built (and 8 views already prove out) the exact SWR primitive this phase
|
||||
needs; the only real gaps are (1) nobody has wired `<KeepAlive>` into the actual remount
|
||||
point yet, and (2) the SWR hook itself needs one small extension (`onActivated`) to work
|
||||
correctly once component instances stop being destroyed on tab switch.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: KeepAlive `include`/`exclude` name-matching bug with async (lazy-loaded) route components
|
||||
**What goes wrong:** `<KeepAlive :include="[...]">`'s name matching can fail to recognize
|
||||
async components (i.e. anything loaded via `component: () => import(...)`) — either
|
||||
caching everything regardless of `include`, or caching nothing.
|
||||
**Why it happens:** Vue historically matched names against the async-component wrapper
|
||||
vnode rather than the resolved inner component; ALL 44 routes in this codebase's
|
||||
`router/index.ts` use `component: () => import(...)` [VERIFIED: grep of
|
||||
neode-ui/src/router/index.ts], so this bug class applies directly here if unpatched.
|
||||
**How to avoid:** The fix (extracting the name from `__asyncResolved`) is reported merged
|
||||
and the project pins `vue: ^3.5.24`, which should include it [CITED:
|
||||
github.com/vuejs/core/issues/11764] — but confirm with a manual smoke test (navigate to
|
||||
an `include`-listed tab, confirm no refetch/remount on revisit; navigate to a
|
||||
NOT-included tab, confirm it DOES remount) before trusting `include`/`exclude` filtering
|
||||
in this codebase. If it misbehaves, `<KeepAlive :max="N">` with no `include`/`exclude` at
|
||||
all (letting LRU eviction do the exclusion work) is documented as working correctly with
|
||||
async components (only `include`/`exclude` were broken), and is a safe fallback for this
|
||||
phase.
|
||||
**Warning signs:** A tab listed in `include` still shows a spinner/remounts every visit
|
||||
during manual verification, or a tab NOT in `include` unexpectedly retains scroll
|
||||
position/state across visits.
|
||||
|
||||
### Pitfall 2: The real remount point is nested inside `Dashboard.vue`, not `App.vue`
|
||||
**What goes wrong:** Wrapping `App.vue`'s outer `<RouterView>` (line ~8, per CONTEXT.md's
|
||||
canonical_refs) produces no visible improvement.
|
||||
**Why it happens:** `App.vue`'s RouterView only ever swaps between top-level route
|
||||
components (`OnboardingWrapper.vue`, `Dashboard.vue`, `NotFound.vue`) — Dashboard itself
|
||||
stays mounted for the entire authenticated session. The actual per-tab remount churn is a
|
||||
SECOND, nested `<RouterView>` inside `Dashboard.vue` (`neode-ui/src/views/Dashboard.vue:87`),
|
||||
which ALSO keys its wrapper `<div>` by `route.path` for `<Transition>` purposes and
|
||||
branches into two different template paths (chat/mesh vs. everything else).
|
||||
**How to avoid:** Scope the KeepAlive work explicitly against `Dashboard.vue`'s nested
|
||||
RouterView, in both of its template branches. Update the phase's canonical reference for
|
||||
future agents.
|
||||
**Warning signs:** After "adding KeepAlive," tab switches still show the intro
|
||||
animation/spinner every time, or profiling still shows a full component-tree teardown on
|
||||
tab switch.
|
||||
|
||||
### Pitfall 3: `useCachedResource` has no reactivation hook — background refresh silently stops working once KeepAlive lands
|
||||
**What goes wrong:** D-01's "renders from cache immediately, refreshes in background" only
|
||||
half-works: the cache-hit instant paint works (KeepAlive preserves the mounted instance
|
||||
and its data), but the "refreshes in background" half depends on `refreshIfStale()` being
|
||||
re-invoked on revisit — and today that only happens at setup time (once) and on window
|
||||
`focus` events (which don't fire on in-SPA tab switches).
|
||||
**Why it happens:** The hook was written before any component used KeepAlive, so it never
|
||||
needed `onActivated`/`onDeactivated`. `onScopeDispose` (used for cleanup today) never fires
|
||||
for a KeepAlive'd component on tab-away — the component is deactivated, not unmounted.
|
||||
**How to avoid:** Add `onActivated(() => refreshIfStale())` to the hook (Pattern 2 above) —
|
||||
safe to do unconditionally since Vue no-ops these hooks for components outside a
|
||||
`<KeepAlive>` boundary.
|
||||
**Warning signs:** A KeepAlive'd tab shows correct instant-paint on revisit but NEVER shows
|
||||
the subtle "refreshing" indicator (D-05) even after the TTL has clearly elapsed, and data
|
||||
visibly goes stale (e.g. mesh peer status frozen at whatever it was on last visit).
|
||||
|
||||
### Pitfall 4: `onMounted`-based one-shot logic breaks silently once a view is KeepAlive'd
|
||||
**What goes wrong:** Per-visit setup that assumed `onMounted` fires on every tab entry
|
||||
(scroll restoration, connection-timeout timers, animation-completion flags) stops running
|
||||
after the first visit.
|
||||
**Why it happens:** `onMounted` fires exactly once per component instance; KeepAlive
|
||||
reuses the instance, so it fires exactly once, ever, for that tab's lifetime in the
|
||||
session. Concretely: `Apps.vue`'s `connectionTimer` (a 15s timeout that only arms once,
|
||||
`onMounted`) and `Server.vue`'s 7-call `onMounted(() => { checkTorStatus(); ...
|
||||
loadFipsSummary() })` initializer would both need review — the latter is fine to leave in
|
||||
`onMounted` if all 7 calls should only ever run once per session (first visit), but WOULD
|
||||
need to move to `onActivated` if any of them should re-run/re-check on every tab revisit.
|
||||
**How to avoid:** During each view's conversion (D-02), explicitly decide per side-effect:
|
||||
"once ever" stays in `onMounted`; "every visit" moves to `onActivated`.
|
||||
**Warning signs:** A feature that worked on first tab visit stops updating/re-arming on
|
||||
subsequent visits within the same session.
|
||||
|
||||
### Pitfall 5: Serial `await` chains masquerading as "already async"
|
||||
**What goes wrong:** Code that looks async-savvy (`async function`, `await` everywhere)
|
||||
can still be a serial waterfall if each `await` blocks the next independent call.
|
||||
**Why it happens:** Found concretely in `ContainerAppDetails.vue:169-172`:
|
||||
`await loadContainer(); await loadLogs(); await loadHealthStatus()` — three independent
|
||||
RPC-backed loads, each fully round-tripping before the next starts. `Server.vue`'s
|
||||
`onMounted` (non-async, fire-and-forget calls) and `Mesh.vue`'s `onMounted`
|
||||
(`await Promise.all([...])`) are both already correctly parallel and should NOT be
|
||||
"fixed" — profiling must distinguish real waterfalls from already-parallel code before
|
||||
touching it (D-02).
|
||||
**How to avoid:** `Promise.all`/`Promise.allSettled` for independent loads (Pattern 3).
|
||||
**Warning signs:** DevTools Network/Performance panel shows RPC calls for one screen
|
||||
starting one-after-another rather than overlapping.
|
||||
|
||||
### Pitfall 6: KeepAlive memory growth on low-power fleet nodes
|
||||
**What goes wrong:** Every kept-alive tab holds its full component tree (refs, computed
|
||||
caches, and for Mesh specifically a live D3 force-graph + Leaflet map instance) in memory
|
||||
indefinitely.
|
||||
**Why it happens:** `<KeepAlive>` with no `max` never evicts. Mesh.vue is 2,651 lines and
|
||||
pulls in both `d3` and `@vue-leaflet/vue-leaflet`/`leaflet` [VERIFIED:
|
||||
neode-ui/package.json + neode-ui/src/views/Mesh.vue] — one of the heaviest views in the
|
||||
app, and one D-03 explicitly wants kept alive anyway (with eviction).
|
||||
**How to avoid:** Set `max` per D-03 (Claude's discretion on the exact number — this
|
||||
research recommends starting around 8, one less than the 10 entries in `TAB_ORDER` from
|
||||
`useRouteTransitions.ts`, so the least-recently-used main tab evicts under memory
|
||||
pressure rather than every tab staying resident forever) and verify actual memory
|
||||
behavior on archi-dev-box, not just the dev workstation, per D-11.
|
||||
**Warning signs:** Memory growth over a session of visiting many tabs; sluggishness
|
||||
returning after extended use even though individual tab switches feel fast at first.
|
||||
|
||||
### Pitfall 7: `isDetailRoute` (the existing "is this a secondary screen" helper) doesn't cover every secondary screen
|
||||
**What goes wrong:** `neode-ui/src/views/dashboard/useRouteTransitions.ts`'s
|
||||
`isDetailRoute()` only recognizes `/apps/:id` and `/marketplace/:id` as detail routes.
|
||||
Routes like `cloud/:folderId`, `cloud/peers/:peerId?`, `server/openwrt`, `goals/:goalId`,
|
||||
`app-session/:appId`, `web5/credentials`, `web5/networking-profits`,
|
||||
`apps/lnd/channels` are ALSO secondary screens per the phase's own definition
|
||||
("screens reached from a tab's main page") but this helper won't flag them.
|
||||
**Why it happens:** The helper was written for background-image/transition purposes, not
|
||||
for KeepAlive scoping, and was never meant to be an exhaustive secondary-screen registry.
|
||||
**How to avoid:** Don't reuse `isDetailRoute` as the source of truth for KeepAlive
|
||||
`include`/`exclude`. Build the KeepAlive `include` list explicitly from the known main-tab
|
||||
component names (Home, Apps, Mesh, Cloud, Server, Web5, Marketplace/Discover, Chat,
|
||||
Settings, Fleet — the `TAB_ORDER` set) rather than trying to derive "not a detail route"
|
||||
generically.
|
||||
**Warning signs:** A secondary screen unexpectedly gets cached (component instance
|
||||
survives navigating away and back) because it slipped through an overly broad `include`
|
||||
pattern.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Existing SWR usage to mirror (already production code — do not reinvent)
|
||||
```typescript
|
||||
// Source: neode-ui/src/views/Cloud.vue (existing, verified) — the pattern main tabs
|
||||
// being converted should follow.
|
||||
const peersResource = useCachedResource<PeerNode[]>({
|
||||
key: 'cloud.peers',
|
||||
fetcher: (signal) => rpcClient.call({ method: 'federation.list-nodes', signal, dedup: true }),
|
||||
// ttlMs left at hook default (30s) here; tune shorter/longer per D-06 as needed
|
||||
})
|
||||
|
||||
async function loadPeers() {
|
||||
await peersResource.refresh()
|
||||
const e = peersResource.entry
|
||||
if (e.error) loadError.value = e.error // keep-last-known-value; surface error in a banner, not a toast
|
||||
}
|
||||
```
|
||||
|
||||
### rpc-client dedup — already available, use for any newly-parallelized group
|
||||
```typescript
|
||||
// Source: neode-ui/src/api/rpc-client.ts (existing)
|
||||
const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({
|
||||
method: 'network.list-interfaces',
|
||||
signal,
|
||||
dedup: true, // collapses concurrent identical calls from multiple mounted consumers
|
||||
maxRetries: 1,
|
||||
})
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|---------------|--------|
|
||||
| Every tab switch fully unmounts/remounts the view (`fetch-on-mount` every visit) | `<KeepAlive>` + `useCachedResource` stale-while-revalidate | This phase | Instant re-paint from cached instance + data on revisit; RPC only fires when actually stale |
|
||||
| Ad-hoc per-view fetch-on-mount with raw `rpcClient.call` (no cache) | `useCachedResource` keyed resource, shared `resources` Pinia store | Already the pattern for 8 views (Monitoring, Cloud, Server, Federation, Credentials, Web5, FIPS cards); this phase extends it to main tabs + remaining secondary screens | Consistent stale-while-revalidate behavior app-wide instead of two different data-loading philosophies coexisting |
|
||||
| Serial `await` chains for independent loads (e.g. `ContainerAppDetails.vue`) | `Promise.all`/`Promise.allSettled` | This phase, per-surface as profiling names it (D-02/D-13) | Removes N×round-trip latency stacking into N round-trips' worth |
|
||||
|
||||
**Deprecated/outdated:** N/A — no library version is being deprecated; this is closing a
|
||||
gap between an already-modern pattern (used in 8 views) and the views that predate it
|
||||
(the main tabs, added before `useCachedResource` existed).
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | Vue 3.5.24 (as pinned, `^3.5.24`) includes the fix for the KeepAlive `include`/`exclude` async-component name-matching bug (vuejs/core #7533/#11764) | Standard Stack, Pitfall 1 | If not actually fixed at the resolved `^3.5.24` patch version, `include`/`exclude`-based KeepAlive scoping could silently cache the wrong views or none at all; the `max`-only fallback noted in Pitfall 1 is the mitigation — verify manually before trusting `include` |
|
||||
| A2 | KeepAlive `max` starting value of ~8 is a reasonable default for archi-dev-box-class hardware | Pitfall 6 | Too high risks memory growth on low-power fleet nodes (D-03's stated concern); too low risks evicting a tab the user just switched away from, defeating the "instant on revisit" goal — this is explicitly Claude's discretion per CONTEXT.md and should be tuned against real on-device memory profiling (D-11), not just picked and shipped |
|
||||
| A3 | The 7 unawaited-but-concurrent calls in `Server.vue`'s `onMounted` (line 831) are all safe to run in parallel with no ordering dependency | "Concrete Findings" implied by Pitfall 5 | If any of the 7 (`checkTorStatus`, `loadNetworkData`, `loadInterfaces`, `loadDiskStatus`, `loadTorServices`, `loadVpnPeers`, `loadFipsSummary`) secretly depends on another's side effect, treating this as "already fine, don't touch" during profiling could miss a real bug — profiling (D-10) should still name Server explicitly rather than skip it on the assumption this code is already optimal |
|
||||
|
||||
**AIUI ride-along (D-14) is tracked as an Open Question, not an Assumption, below** —
|
||||
it's a missing-dependency finding (verified via filesystem check), not a training-data
|
||||
guess.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Where does the AIUI source actually live, and does it already support the D-14
|
||||
query-param-style controls?**
|
||||
- What we know: `apps/aiui/manifest.yml` only describes a prebuilt container image
|
||||
(`localhost/archipelago-aiui:latest`) — no source in this checkout. `neode-ui`'s own
|
||||
`package.json` `dev:mock` script and `scripts/setup-aiui-server.sh` both reference a
|
||||
SIBLING repo at `../../AIUI` (i.e., `<parent-of-archy>/AIUI`) as AIUI's real source,
|
||||
with `dev:mock` gracefully falling back to "chat will show placeholder" when it's
|
||||
absent [VERIFIED: read of both files]. A direct filesystem check on this machine
|
||||
found NO `AIUI` directory next to `archy` [VERIFIED: `ls` of the parent directory].
|
||||
`Chat.vue` already constructs the iframe URL with query params
|
||||
(`embedded=true&hideClose=true&mockArchy=1&seed=1`), so the mechanism for passing a
|
||||
"start expanded" / "start on chat view" flag into AIUI from neode-ui plausibly exists
|
||||
as a pattern, but whether AIUI's OWN code currently reads/honors such params for
|
||||
expanded-state or mobile-view-default is unknown without that source.
|
||||
- What's unclear: Whether AIUI's source is checked out on the machine that will
|
||||
actually execute this phase's plans (the user's memory notes the ThinkPad, `.116`, as
|
||||
the primary build server — AIUI may live there and not on whatever machine ran this
|
||||
research), and whether it already has a query-param or postMessage hook for these two
|
||||
UX defaults or needs new code added on the AIUI side.
|
||||
- Recommendation: The planner should add an early checkpoint/precondition task for D-14
|
||||
confirming AIUI source location and current param support BEFORE scoping the actual
|
||||
UX-default change as line-of-code work — this is a blocking dependency the phase
|
||||
cannot resolve from within `archy` alone if the source truly isn't reachable from the
|
||||
execution environment.
|
||||
|
||||
2. **What is the precise profiling methodology/output format for the D-10 findings doc?**
|
||||
- What we know: D-10 requires a COMMITTED doc, written and committed before fixes land,
|
||||
mapping each slow surface → measured cause → intended fix, for the D-09 surface list.
|
||||
- What's unclear: Whether "measured" means DevTools Performance-panel screenshots/traces
|
||||
attached in the doc, `performance.mark`/`measure` numbers pasted in, or a narrative
|
||||
description backed by code inspection (as this research itself did for several
|
||||
surfaces, below).
|
||||
- Recommendation: Treat this research's "Concrete Findings Per Surface" (next section)
|
||||
as a starting hypothesis set, not a substitute for D-10's own profiling pass — the
|
||||
plan should have the profiling task actually run DevTools/Performance-API timing on
|
||||
archi-dev-box (or at minimum the dev workstation with network throttling toward
|
||||
archi-dev) and record real numbers, since several "looks fine in code" surfaces
|
||||
(e.g. `Mesh.vue`'s already-parallel `Promise.all`) may still be slow for reasons this
|
||||
static read cannot see (render cost of the D3 graph, RPC latency to a real node vs.
|
||||
the mock backend, etc.).
|
||||
|
||||
## Concrete Findings Per Surface (seed data for the required PERF-01 profiling pass)
|
||||
|
||||
Code-level pre-scan of each D-09 surface, to speed up (not replace) profiling:
|
||||
|
||||
| Surface | Uses `useCachedResource` today? | `onMounted` fetch pattern found | Likely cause category (to CONFIRM by profiling) |
|
||||
|---|---|---|---|
|
||||
| Apps (`Apps.vue`) | No — reads from `useAppStore`'s WebSocket-pushed `packages` state, no per-mount RPC | `onMounted` only arms a 15s connection-timeout timer; no fetch | Likely **remount storm only** (loses scroll/search/tab-selection state and re-runs list sort/filter/animation on every visit) — data itself is already live via WebSocket, not refetched |
|
||||
| Marketplace/Discover (the "app store" the user called out specifically) | Not checked this session | Not checked this session | **Needs direct profiling** — user said "often app store," but Apps.vue itself looks WebSocket-backed; the actual store/discover views need their own pass, not inherited from Apps.vue's analysis |
|
||||
| Mesh (`Mesh.vue`, 2,651 lines, D3 + Leaflet) | No | `onMounted(async () => { await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()]) })` — already parallelized | **Uncached fetch + remount storm** (6 RPC groups already run in parallel, so NOT a waterfall — but nothing is cached, so all 6 re-run on every tab revisit; plus a D3/Leaflet re-render cost on every remount) |
|
||||
| Cloud (`Cloud.vue`) | Yes — `peersResource`/`countsResource` already on `useCachedResource` | `onMounted(async () => { loadCounts(); await loadPeers(); void loadPeerFiles() })` — mostly cache-gated already | Likely **already close to fixed**; profiling may show this needs little to no work, or only KeepAlive (component-instance persistence), not new caching |
|
||||
| Server (`Server.vue`, 889 lines) | Partially (some sub-cards use the hook per grep) | `onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() })` — 7 independent fire-and-forget calls, already effectively parallel (not `await`ed serially) | **Uncached fetch** (7 RPC calls fire fresh on every visit; already parallel, so not a waterfall) |
|
||||
| ContainerAppDetails (secondary screen for installed apps) | No | `onMounted(async () => { await loadContainer(); await loadLogs(); await loadHealthStatus() })` | **Confirmed serial RPC waterfall** — concrete Pattern 3 fix candidate |
|
||||
| AppDetails (secondary screen) | No | `onMounted(() => { loadBitcoinSync(); loadCredentials() })` — both fire-and-forget, not awaited sequentially | Likely fine as-is (already parallel); candidate for `useCachedResource` conversion per D-04 mainly for the instant-repeat-visit requirement (PERF-03), not a waterfall fix |
|
||||
| Wallet / send flows | Not located/checked this session (no `Wallet.vue` found under `views/`; likely spread across `AppDetails.vue`'s Bitcoin-app-specific code and modal components) | Not checked | **Needs direct profiling** — locate the actual wallet/send-flow components first |
|
||||
| Web5 (`web5/Web5.vue`) | Yes (in the useCachedResource grep list) | Not inspected in detail this session | Likely **already close to fixed**; confirm via profiling rather than assuming |
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Node.js | Frontend build/dev | ✓ | v24.14.1 [VERIFIED: `node --version`] | — |
|
||||
| npm | Frontend build/dev | ✓ | 11.11.0 [VERIFIED: `npm --version`] | — |
|
||||
| Vitest | Unit tests for the `onActivated` hook extension | ✓ (already configured, `neode-ui/vitest.config.ts`) | 3.1.1 [VERIFIED: package.json] | — |
|
||||
| Playwright | Optional e2e smoke of tab-switch behavior | ✓ (`neode-ui/e2e/`) | 1.58.2 [VERIFIED: package.json] | Manual verification on archi-dev-box is the phase's actual pass bar (D-11) regardless |
|
||||
| archi-dev-box (on-device verification target, D-11) | PERF-01/02/03 sign-off | ✓ — resolvable from this environment (multiple addresses returned) [VERIFIED: `getent hosts archi-dev-box`] | — | — |
|
||||
| AIUI source repo (`../../AIUI`, sibling to `archy`) | D-14 UX defaults | ✗ — not present on this machine [VERIFIED: `ls` of parent directory] | — | Must be located (possibly on the ThinkPad `.116` build server per project memory) before D-14 can be implemented as code — see Open Question 1 |
|
||||
|
||||
**Missing dependencies with no fallback:**
|
||||
- AIUI source repo for D-14 — there is no in-repo fallback; the two small UX defaults
|
||||
cannot be implemented without either the AIUI source or a confirmed existing
|
||||
neode-ui-side hook (iframe query param) that AIUI already honors.
|
||||
|
||||
**Missing dependencies with fallback:**
|
||||
- None of the frontend-perf-specific work (KeepAlive, useCachedResource extension,
|
||||
Promise.all fixes) has any missing dependency — everything needed is already installed.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Vitest 3.1.1 [VERIFIED: package.json], jsdom environment, globals enabled |
|
||||
| Config file | `neode-ui/vitest.config.ts` |
|
||||
| Quick run command | `npm run test -- <path/to/file>.test.ts` (run from `neode-ui/`) |
|
||||
| Full suite command | `npm run test` (from `neode-ui/`); `npm run test:watch` for iteration |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| PERF-01 | Slow surfaces profiled, causes named before fixes land | manual (committed findings doc, D-10) | N/A — not an automatable assertion; verified by the doc's existence + review | ❌ Wave 0 — this is a doc deliverable, not a test |
|
||||
| PERF-02 | Main-tab component instance NOT recreated on revisit; cached data renders synchronously | unit (`@vue/test-utils` + Vitest, mounting `Dashboard.vue`'s router-view structure or an isolated harness around `<KeepAlive>`) | `npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts` (NEW) | ❌ Wave 0 — needs a new test file |
|
||||
| PERF-02 | `useCachedResource`'s `onActivated` extension actually calls `refreshIfStale()` on reactivation, and is a no-op when not inside `<KeepAlive>` | unit | `npm run test -- src/composables/__tests__/useCachedResource.test.ts` (NEW — no existing test file for this composable was found) | ❌ Wave 0 |
|
||||
| PERF-03 | Repeat opens of a secondary screen (e.g. AppDetails) resolve from cache without re-invoking the fetcher | unit (spy call count assertion on the fetcher passed to `useCachedResource`) | `npm run test -- src/views/__tests__/AppDetails.test.ts` (NEW, or extend an existing view test if one exists) | ❌ Wave 0 |
|
||||
| PERF-03 (on-device pass bar, D-11) | No visible spinner/blank on revisit of any tab/secondary screen already visited this session, on archi-dev-box | manual only | N/A — inherently a live/manual on-device check per the phase's own success criteria | ❌ Wave 0 — manual-only by design, not a gap to fill with automation |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `npm run test -- <touched files>` (quick, targeted)
|
||||
- **Per wave merge:** `npm run test` (full Vitest suite) + a manual archi-dev-box
|
||||
tab-switch/secondary-screen walkthrough for whatever surfaces that wave touched
|
||||
- **Phase gate:** Full Vitest suite green, PLUS the committed D-10 findings doc exists and
|
||||
is referenced, PLUS a full manual pass-bar walkthrough on archi-dev-box per D-11 before
|
||||
`/gsd-verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `neode-ui/src/composables/__tests__/useCachedResource.test.ts` — no existing test
|
||||
file for this composable; needed to cover the new `onActivated` behavior and confirm
|
||||
existing sticky-ready/keep-last-value/dedup semantics aren't regressed by the change
|
||||
- [ ] `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` — new test harness for
|
||||
the KeepAlive-wrapped nested router-view (component identity survives a simulated route
|
||||
change to an `include`-listed path; does NOT survive for a non-included path)
|
||||
- [ ] A committed profiling findings doc at
|
||||
`.planning/phases/02-ui-performance/` (per D-10) — this is a deliverable, not a test
|
||||
file, but Wave 0 of the plan should treat "produce this doc" as a hard prerequisite
|
||||
gating all per-surface fix waves, per D-02/D-10
|
||||
|
||||
## Security Domain
|
||||
|
||||
`security_enforcement` is absent from `.planning/config.json` → treated as enabled.
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | No | This phase touches no auth/session code paths |
|
||||
| V3 Session Management | No | No session logic changes |
|
||||
| V4 Access Control | Conditional — only if D-12/D-13 leads to a NEW aggregate RPC endpoint | Any new endpoint must sit behind whatever RBAC/session middleware existing sibling `core/` RPC handlers already use — additive-only per D-12 means copying the existing auth-check pattern, not designing a new one |
|
||||
| V5 Input Validation | Conditional — same trigger as V4 | Any new aggregate endpoint's parameters must be validated the same way existing handlers validate theirs (existing project convention, not a new library) |
|
||||
| V6 Cryptography | No | Nothing in this phase touches crypto/secrets |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| A new aggregate RPC endpoint accidentally exposing more fields than the requesting view needs (over-fetch through a "convenience" batch endpoint) | Information Disclosure | Shape the aggregate response to exactly what the calling screen renders — mirror the discipline already visible in existing typed RPC response interfaces in `rpc-client.ts` |
|
||||
| sessionStorage snapshot (D-08) persisting sensitive per-user data longer than intended | Information Disclosure | Already mitigated by the existing `persist: false` opt-out for large/sensitive payloads (D-08); when converting a new view, explicitly decide persist:true/false rather than defaulting blindly |
|
||||
|
||||
No new authentication, authorization, or cryptographic surfaces are introduced by this
|
||||
phase — the security review here is narrow by design, matching the phase's actual scope.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- `neode-ui/src/composables/useCachedResource.ts`, `neode-ui/src/stores/resources.ts`,
|
||||
`neode-ui/src/api/rpc-client.ts`, `neode-ui/src/App.vue`, `neode-ui/src/views/Dashboard.vue`,
|
||||
`neode-ui/src/views/dashboard/useRouteTransitions.ts`, `neode-ui/src/router/index.ts`,
|
||||
`neode-ui/src/views/{Apps,Mesh,Cloud,Server,AppDetails,ContainerAppDetails}.vue`,
|
||||
`neode-ui/package.json`, `neode-ui/vitest.config.ts`, `apps/aiui/manifest.yml`,
|
||||
`scripts/setup-aiui-server.sh` — all read directly this session.
|
||||
- [RouterView slot | Vue Router](https://router.vuejs.org/guide/advanced/router-view-slot) —
|
||||
confirms the scoped-slot KeepAlive/Transition pattern is the only supported form in
|
||||
Vue Router 4.
|
||||
- [KeepAlive | Vue.js](https://vuejs.org/guide/built-ins/keep-alive) — `include`/`exclude`
|
||||
name matching, `max` LRU eviction, `activated`/`deactivated` hook semantics.
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [KeepAlive include/exclude parameters do not work as expected for asynchronously loaded router views · Issue #11764 · vuejs/core](https://github.com/vuejs/core/issues/11764) —
|
||||
WebFetch-summarized; describes the bug and states it was fixed by extracting the name
|
||||
from `__asyncResolved` — the exact patch-version boundary was not independently
|
||||
cross-verified against a changelog this session (see Assumption A1).
|
||||
- WebSearch results on `onActivated`/`onDeactivated` being a safe no-op outside
|
||||
`<KeepAlive>` (multiple Vue lifecycle-hook explainer articles, cross-checked against the
|
||||
official Vue lifecycle-hooks API reference).
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None — no unverified WebSearch-only claims were used to make a prescriptive
|
||||
recommendation in this document; the one external claim with residual uncertainty (A1)
|
||||
is explicitly flagged with a manual-verification mitigation rather than presented as fact.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — no new libraries; every primitive cited was read directly from
|
||||
the codebase.
|
||||
- Architecture: HIGH — the KeepAlive insertion point, the `onActivated` gap, and the
|
||||
serial-waterfall example were all found by direct code inspection, not inferred.
|
||||
- Pitfalls: HIGH for pitfalls 1–7 (code-grounded); MEDIUM for the exact Vue patch-version
|
||||
boundary in Pitfall 1 (external GitHub issue, not independently changelog-verified).
|
||||
- AIUI ride-along (D-14): LOW — genuinely blocked pending source-location confirmation;
|
||||
flagged as Open Question 1, not glossed over.
|
||||
|
||||
**Research date:** 2026-07-30
|
||||
**Valid until:** 2026-08-13 (30 days is generous for a fast-moving frontend area; if the
|
||||
AIUI repo location or the Vue KeepAlive patch status changes, re-verify before relying on
|
||||
this doc)
|
||||
Loading…
x
Reference in New Issue
Block a user