Files
archy/.planning/phases/02-ui-performance/02-RESEARCH.md

752 lines
55 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (RESOLVED)
Both questions below were carried into planning and are operationally resolved by the
phase plan set — resolution is a plan mechanism, not a research answer:
- **Q1 (AIUI source/param support) → resolved by `02-07-PLAN.md` Task 1**, which searches
for the checkout (including the `.116` build server), records the embed-parameter
contract in `02-AIUI-D14.md`, and gates Task 2 behind a `<precondition>` that halts the
plan if `## Source Location` records the search as exhausted — D-14 blocks rather than
silently shrinks.
- **Q2 (profiling methodology/output format) → resolved by `02-01-PLAN.md`**, whose Task 1
builds a re-runnable Playwright harness emitting `02-PERF-BASELINE.json` and whose Task 3
requires a `## Method` section in `02-FINDINGS.md` naming target, harness path, run
command, sample count and field meanings, with every non-`unmeasured` row citing the
baseline field that justifies its cause.
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 17 (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)