55 KiB
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) ANDuseCachedResourcestale-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
maxinstance 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
useCachedResourcekeyed 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
maxcap 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:
# 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:
<!-- 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:
// 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:
// 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 swapsOnboardingWrapper.vue↔Dashboard.vue↔ 404 — it doesn't remount on tab switch. The real fix must land inDashboard.vue's nested RouterView. Fixing the wrong RouterView would look correct in code review but produce zero observed improvement, sinceDashboard.vueitself was never the thing remounting. - Blanket
<KeepAlive>with noinclude/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+ manualsessionStorage.getItem) instead of extendinguseCachedResource— this is exactly the duplication the canonical_refs explicitly warn against. - Treating
onMountedas "runs once per view visit" once KeepAlive is added: it does not —onMountedonly fires on first mount. Any per-visit logic (scroll restore, one-shot animation flags likeApps.vue'sappsAnimationDone,Server.vue'sconnectionTimersetup) that assumed "onMounted = every view entry" must move toonActivated/onDeactivatedonce 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)
// 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
// 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.mdTask 1, which searches for the checkout (including the.116build server), records the embed-parameter contract in02-AIUI-D14.md, and gates Task 2 behind a<precondition>that halts the plan if## Source Locationrecords 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 emitting02-PERF-BASELINE.jsonand whose Task 3 requires a## Methodsection in02-FINDINGS.mdnaming target, harness path, run command, sample count and field meanings, with every non-unmeasuredrow citing the baseline field that justifies its cause.
-
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.ymlonly describes a prebuilt container image (localhost/archipelago-aiui:latest) — no source in this checkout.neode-ui's ownpackage.jsondev:mockscript andscripts/setup-aiui-server.shboth reference a SIBLING repo at../../AIUI(i.e.,<parent-of-archy>/AIUI) as AIUI's real source, withdev:mockgracefully falling back to "chat will show placeholder" when it's absent [VERIFIED: read of both files]. A direct filesystem check on this machine found NOAIUIdirectory next toarchy[VERIFIED:lsof the parent directory].Chat.vuealready 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
archyalone if the source truly isn't reachable from the execution environment.
- What we know:
-
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/measurenumbers 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-parallelPromise.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 awaited 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 newonActivatedbehavior and confirm existing sticky-ready/keep-last-value/dedup semantics aren't regressed by the changeneode-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 aninclude-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 — confirms the scoped-slot KeepAlive/Transition pattern is the only supported form in Vue Router 4.
- KeepAlive | Vue.js —
include/excludename matching,maxLRU eviction,activated/deactivatedhook semantics.
Secondary (MEDIUM confidence)
- KeepAlive include/exclude parameters do not work as expected for asynchronously loaded router views · Issue #11764 · vuejs/core —
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/onDeactivatedbeing 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
onActivatedgap, 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)