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

55 KiB
Raw Blame History

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:

# 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.
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 swaps OnboardingWrapper.vueDashboard.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)

// 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.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 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 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 — confirms the scoped-slot KeepAlive/Transition pattern is the only supported form in Vue Router 4.
  • KeepAlive | Vue.jsinclude/exclude name matching, max LRU eviction, activated/deactivated hook semantics.

Secondary (MEDIUM confidence)

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)