# 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 (from CONTEXT.md) ### Locked Decisions **Caching Approach (PERF-02)** - **D-01:** Main tabs use BOTH `` around the RouterView (component instances, scroll position, and in-page state survive tab switches) AND `useCachedResource` stale-while-revalidate for their data. Today no `` 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.) ## 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) | ## 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 `` 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** `` inside `Dashboard.vue` (`neode-ui/src/views/Dashboard.vue:87`), which is also keyed by `route.path` for its `` and branches into two different wrapper `
`s (a chat/mesh branch and a default branch). `` must wrap `` 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 `` 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 `` 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 `` | 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 `` | vue-router 4.6.3 (already installed) [VERIFIED: neode-ui/package.json] | Only supported way to interpose ``/`` between the router and the rendered view in Vue Router 4 | Directly wrapping `` with `` 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 `` is actually caching the intended component names | ### Alternatives Considered | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | `` | Custom `v-show`-based tab persistence (render all main tabs simultaneously, toggle visibility) | Avoids the KeepAlive/async-component name-matching bug entirely, but keeps ALL main-tab DOM (D3 graph, Leaflet map, all lists) mounted and reactive permanently — worse memory/CPU on low-power nodes than a capped KeepAlive; rejected, out of step with D-03's bounded-memory requirement | | Client-side `Promise.all` (D-13's default) | New aggregate RPC endpoint per screen | Aggregate endpoints reduce round-trips further but require backend changes (additive-only per D-12) and add a new response shape to maintain; reserve for screens with 3+ genuinely dependent calls, per D-13 | | `useCachedResource` extension | A dedicated third-party SWR library (e.g. a Vue port of `swr`/`vue-query`) | Would duplicate functionality the project already built and tuned (sticky-ready, sessionStorage snapshot, keep-last-value) and adds an external dependency for zero net capability gain — explicitly against the phase's canonical_refs guidance to extend, not reinvent | **Installation:** ```bash # No new packages required — KeepAlive is Vue 3 core, Promise.all is native JS, # useCachedResource/resources/rpc-client already exist in the codebase. ``` **Version verification:** `neode-ui/package.json` pins `"vue": "^3.5.24"` and `"vue-router": "^4.6.3"` [VERIFIED: package.json read directly]. The async-component KeepAlive `include`/`exclude` name-matching bug (vuejs/core #7533 / #11764) was reported fixed by extracting the component name from `__asyncResolved` rather than the wrapper vnode; treat as fixed at 3.5.24 but confirm with a quick manual smoke test on the actual lazy-loaded routes in this repo before relying on `include`/`exclude` filtering (see Common Pitfalls #1) [CITED: github.com/vuejs/core/issues/11764]. ## Package Legitimacy Audit **No external packages are being installed for this phase.** Every primitive needed (``, `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 ◄── the │ real ▼ remount point │ (line ~87, ▼ neode-ui/src/ ◄── 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 `` with ``/`` — the scoped-slot form is mandatory. **When to use:** The single nested `` in `Dashboard.vue` that renders all tab/secondary-screen content. **Example:** ```vue ``` Note: `:key="route.path"` is safe to keep on `` 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 `
`s depending on `route.path` (a chat/mesh branch with different classes, and a default branch with scroll-container classes). Both branches wrap `` — the KeepAlive needs to live inside BOTH branches (or the branching needs to move to apply classes to a single shared wrapper so one KeepAlive covers both cases). This is real template surgery, not a one-line wrap. ### Pattern 2: `useCachedResource` with `onActivated` revalidation (extension needed) **What:** Add reactivation-triggered staleness checks so KeepAlive'd tabs actually background-refresh on revisit, not just on window focus. **When to use:** Inside `useCachedResource` itself, so every consuming view benefits without per-view changes. **Example:** ```typescript // Source: neode-ui/src/composables/useCachedResource.ts (existing) + Vue's onActivated // (vuejs.org/api/composition-api-lifecycle.html#onactivated) — safe no-op outside // , so this is safe to add unconditionally for every consumer. import { onActivated } from 'vue' // ... inside useCachedResource, alongside the existing onScopeDispose block: if (getCurrentScope()) { onActivated(() => refreshIfStale()) } ``` This mirrors the existing `if (opts.immediate ?? true) refreshIfStale()` call at setup time — `onActivated` also fires on first mount (per Vue's lifecycle docs), so the two calls are redundant-but-harmless on first mount (the store's `inflight` map dedupes them) and the ONLY source of a stale-check on every subsequent reactivation. ### Pattern 3: Parallelize a serial `onMounted` waterfall **What:** Replace sequential `await` chains with `Promise.all`/`Promise.allSettled`. **When to use:** Any `onMounted` (or other lifecycle hook) that awaits independent RPC calls one after another with no data dependency between them. **Example — the actual bug found in this codebase, `ContainerAppDetails.vue:169-172`:** ```typescript // BEFORE (serial waterfall — 3 sequential round-trips): onMounted(async () => { await loadContainer() await loadLogs() await loadHealthStatus() }) // AFTER (parallel — same 3 calls, one round-trip's worth of latency): onMounted(async () => { await Promise.allSettled([loadContainer(), loadLogs(), loadHealthStatus()]) }) ``` `loadContainer`/`loadLogs`/`loadHealthStatus` each set their own `loading` ref independently and don't read each other's results, so this is a safe parallelization — confirm this per-screen during profiling (D-02), don't blanket-apply. ### Anti-Patterns to Avoid - **Wrapping `App.vue`'s outer `` with `` and calling it done:** this RouterView only ever swaps `OnboardingWrapper.vue` ↔ `Dashboard.vue` ↔ 404 — it doesn't remount on tab switch. The real fix must land in `Dashboard.vue`'s nested RouterView. Fixing the wrong RouterView would look correct in code review but produce zero observed improvement, since `Dashboard.vue` itself was never the thing remounting. - **Blanket `` 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 `` | 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 `` 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:** ``'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, `` 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 `` (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 `` inside `Dashboard.vue` (`neode-ui/src/views/Dashboard.vue:87`), which ALSO keys its wrapper `
` by `route.path` for `` 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 `` 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:** `` with no `max` never evicts. Mesh.vue is 2,651 lines and pulls in both `d3` and `@vue-leaflet/vue-leaflet`/`leaflet` [VERIFIED: neode-ui/package.json + neode-ui/src/views/Mesh.vue] — one of the heaviest views in the app, and one D-03 explicitly wants kept alive anyway (with eviction). **How to avoid:** Set `max` per D-03 (Claude's discretion on the exact number — this research recommends starting around 8, one less than the 10 entries in `TAB_ORDER` from `useRouteTransitions.ts`, so the least-recently-used main tab evicts under memory pressure rather than every tab staying resident forever) and verify actual memory behavior on archi-dev-box, not just the dev workstation, per D-11. **Warning signs:** Memory growth over a session of visiting many tabs; sluggishness returning after extended use even though individual tab switches feel fast at first. ### Pitfall 7: `isDetailRoute` (the existing "is this a secondary screen" helper) doesn't cover every secondary screen **What goes wrong:** `neode-ui/src/views/dashboard/useRouteTransitions.ts`'s `isDetailRoute()` only recognizes `/apps/:id` and `/marketplace/:id` as detail routes. Routes like `cloud/:folderId`, `cloud/peers/:peerId?`, `server/openwrt`, `goals/:goalId`, `app-session/:appId`, `web5/credentials`, `web5/networking-profits`, `apps/lnd/channels` are ALSO secondary screens per the phase's own definition ("screens reached from a tab's main page") but this helper won't flag them. **Why it happens:** The helper was written for background-image/transition purposes, not for KeepAlive scoping, and was never meant to be an exhaustive secondary-screen registry. **How to avoid:** Don't reuse `isDetailRoute` as the source of truth for KeepAlive `include`/`exclude`. Build the KeepAlive `include` list explicitly from the known main-tab component names (Home, Apps, Mesh, Cloud, Server, Web5, Marketplace/Discover, Chat, Settings, Fleet — the `TAB_ORDER` set) rather than trying to derive "not a detail route" generically. **Warning signs:** A secondary screen unexpectedly gets cached (component instance survives navigating away and back) because it slipped through an overly broad `include` pattern. ## Code Examples ### Existing SWR usage to mirror (already production code — do not reinvent) ```typescript // Source: neode-ui/src/views/Cloud.vue (existing, verified) — the pattern main tabs // being converted should follow. const peersResource = useCachedResource({ key: 'cloud.peers', fetcher: (signal) => rpcClient.call({ method: 'federation.list-nodes', signal, dedup: true }), // ttlMs left at hook default (30s) here; tune shorter/longer per D-06 as needed }) async function loadPeers() { await peersResource.refresh() const e = peersResource.entry if (e.error) loadError.value = e.error // keep-last-known-value; surface error in a banner, not a toast } ``` ### rpc-client dedup — already available, use for any newly-parallelized group ```typescript // Source: neode-ui/src/api/rpc-client.ts (existing) const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces', signal, dedup: true, // collapses concurrent identical calls from multiple mounted consumers maxRetries: 1, }) ``` ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|---------------|--------| | Every tab switch fully unmounts/remounts the view (`fetch-on-mount` every visit) | `` + `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 `` 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., `/AIUI`) as AIUI's real source, with `dev:mock` gracefully falling back to "chat will show placeholder" when it's absent [VERIFIED: read of both files]. A direct filesystem check on this machine found NO `AIUI` directory next to `archy` [VERIFIED: `ls` of the parent directory]. `Chat.vue` already constructs the iframe URL with query params (`embedded=true&hideClose=true&mockArchy=1&seed=1`), so the mechanism for passing a "start expanded" / "start on chat view" flag into AIUI from neode-ui plausibly exists as a pattern, but whether AIUI's OWN code currently reads/honors such params for expanded-state or mobile-view-default is unknown without that source. - What's unclear: Whether AIUI's source is checked out on the machine that will actually execute this phase's plans (the user's memory notes the ThinkPad, `.116`, as the primary build server — AIUI may live there and not on whatever machine ran this research), and whether it already has a query-param or postMessage hook for these two UX defaults or needs new code added on the AIUI side. - Recommendation: The planner should add an early checkpoint/precondition task for D-14 confirming AIUI source location and current param support BEFORE scoping the actual UX-default change as line-of-code work — this is a blocking dependency the phase cannot resolve from within `archy` alone if the source truly isn't reachable from the execution environment. 2. **What is the precise profiling methodology/output format for the D-10 findings doc?** - What we know: D-10 requires a COMMITTED doc, written and committed before fixes land, mapping each slow surface → measured cause → intended fix, for the D-09 surface list. - What's unclear: Whether "measured" means DevTools Performance-panel screenshots/traces attached in the doc, `performance.mark`/`measure` numbers pasted in, or a narrative description backed by code inspection (as this research itself did for several surfaces, below). - Recommendation: Treat this research's "Concrete Findings Per Surface" (next section) as a starting hypothesis set, not a substitute for D-10's own profiling pass — the plan should have the profiling task actually run DevTools/Performance-API timing on archi-dev-box (or at minimum the dev workstation with network throttling toward archi-dev) and record real numbers, since several "looks fine in code" surfaces (e.g. `Mesh.vue`'s already-parallel `Promise.all`) may still be slow for reasons this static read cannot see (render cost of the D3 graph, RPC latency to a real node vs. the mock backend, etc.). ## Concrete Findings Per Surface (seed data for the required PERF-01 profiling pass) Code-level pre-scan of each D-09 surface, to speed up (not replace) profiling: | Surface | Uses `useCachedResource` today? | `onMounted` fetch pattern found | Likely cause category (to CONFIRM by profiling) | |---|---|---|---| | Apps (`Apps.vue`) | No — reads from `useAppStore`'s WebSocket-pushed `packages` state, no per-mount RPC | `onMounted` only arms a 15s connection-timeout timer; no fetch | Likely **remount storm only** (loses scroll/search/tab-selection state and re-runs list sort/filter/animation on every visit) — data itself is already live via WebSocket, not refetched | | Marketplace/Discover (the "app store" the user called out specifically) | Not checked this session | Not checked this session | **Needs direct profiling** — user said "often app store," but Apps.vue itself looks WebSocket-backed; the actual store/discover views need their own pass, not inherited from Apps.vue's analysis | | Mesh (`Mesh.vue`, 2,651 lines, D3 + Leaflet) | No | `onMounted(async () => { await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()]) })` — already parallelized | **Uncached fetch + remount storm** (6 RPC groups already run in parallel, so NOT a waterfall — but nothing is cached, so all 6 re-run on every tab revisit; plus a D3/Leaflet re-render cost on every remount) | | Cloud (`Cloud.vue`) | Yes — `peersResource`/`countsResource` already on `useCachedResource` | `onMounted(async () => { loadCounts(); await loadPeers(); void loadPeerFiles() })` — mostly cache-gated already | Likely **already close to fixed**; profiling may show this needs little to no work, or only KeepAlive (component-instance persistence), not new caching | | Server (`Server.vue`, 889 lines) | Partially (some sub-cards use the hook per grep) | `onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() })` — 7 independent fire-and-forget calls, already effectively parallel (not `await`ed serially) | **Uncached fetch** (7 RPC calls fire fresh on every visit; already parallel, so not a waterfall) | | ContainerAppDetails (secondary screen for installed apps) | No | `onMounted(async () => { await loadContainer(); await loadLogs(); await loadHealthStatus() })` | **Confirmed serial RPC waterfall** — concrete Pattern 3 fix candidate | | AppDetails (secondary screen) | No | `onMounted(() => { loadBitcoinSync(); loadCredentials() })` — both fire-and-forget, not awaited sequentially | Likely fine as-is (already parallel); candidate for `useCachedResource` conversion per D-04 mainly for the instant-repeat-visit requirement (PERF-03), not a waterfall fix | | Wallet / send flows | Not located/checked this session (no `Wallet.vue` found under `views/`; likely spread across `AppDetails.vue`'s Bitcoin-app-specific code and modal components) | Not checked | **Needs direct profiling** — locate the actual wallet/send-flow components first | | Web5 (`web5/Web5.vue`) | Yes (in the useCachedResource grep list) | Not inspected in detail this session | Likely **already close to fixed**; confirm via profiling rather than assuming | ## Environment Availability | Dependency | Required By | Available | Version | Fallback | |------------|------------|-----------|---------|----------| | Node.js | Frontend build/dev | ✓ | v24.14.1 [VERIFIED: `node --version`] | — | | npm | Frontend build/dev | ✓ | 11.11.0 [VERIFIED: `npm --version`] | — | | Vitest | Unit tests for the `onActivated` hook extension | ✓ (already configured, `neode-ui/vitest.config.ts`) | 3.1.1 [VERIFIED: package.json] | — | | Playwright | Optional e2e smoke of tab-switch behavior | ✓ (`neode-ui/e2e/`) | 1.58.2 [VERIFIED: package.json] | Manual verification on archi-dev-box is the phase's actual pass bar (D-11) regardless | | archi-dev-box (on-device verification target, D-11) | PERF-01/02/03 sign-off | ✓ — resolvable from this environment (multiple addresses returned) [VERIFIED: `getent hosts archi-dev-box`] | — | — | | AIUI source repo (`../../AIUI`, sibling to `archy`) | D-14 UX defaults | ✗ — not present on this machine [VERIFIED: `ls` of parent directory] | — | Must be located (possibly on the ThinkPad `.116` build server per project memory) before D-14 can be implemented as code — see Open Question 1 | **Missing dependencies with no fallback:** - AIUI source repo for D-14 — there is no in-repo fallback; the two small UX defaults cannot be implemented without either the AIUI source or a confirmed existing neode-ui-side hook (iframe query param) that AIUI already honors. **Missing dependencies with fallback:** - None of the frontend-perf-specific work (KeepAlive, useCachedResource extension, Promise.all fixes) has any missing dependency — everything needed is already installed. ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | Vitest 3.1.1 [VERIFIED: package.json], jsdom environment, globals enabled | | Config file | `neode-ui/vitest.config.ts` | | Quick run command | `npm run test -- .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 ``) | `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 `` | 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 -- ` (quick, targeted) - **Per wave merge:** `npm run test` (full Vitest suite) + a manual archi-dev-box tab-switch/secondary-screen walkthrough for whatever surfaces that wave touched - **Phase gate:** Full Vitest suite green, PLUS the committed D-10 findings doc exists and is referenced, PLUS a full manual pass-bar walkthrough on archi-dev-box per D-11 before `/gsd-verify-work` ### Wave 0 Gaps - [ ] `neode-ui/src/composables/__tests__/useCachedResource.test.ts` — no existing test file for this composable; needed to cover the new `onActivated` behavior and confirm existing sticky-ready/keep-last-value/dedup semantics aren't regressed by the change - [ ] `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` — new test harness for the KeepAlive-wrapped nested router-view (component identity survives a simulated route change to an `include`-listed path; does NOT survive for a non-included path) - [ ] A committed profiling findings doc at `.planning/phases/02-ui-performance/` (per D-10) — this is a deliverable, not a test file, but Wave 0 of the plan should treat "produce this doc" as a hard prerequisite gating all per-surface fix waves, per D-02/D-10 ## Security Domain `security_enforcement` is absent from `.planning/config.json` → treated as enabled. ### Applicable ASVS Categories | ASVS Category | Applies | Standard Control | |---------------|---------|-----------------| | V2 Authentication | No | This phase touches no auth/session code paths | | V3 Session Management | No | No session logic changes | | V4 Access Control | Conditional — only if D-12/D-13 leads to a NEW aggregate RPC endpoint | Any new endpoint must sit behind whatever RBAC/session middleware existing sibling `core/` RPC handlers already use — additive-only per D-12 means copying the existing auth-check pattern, not designing a new one | | V5 Input Validation | Conditional — same trigger as V4 | Any new aggregate endpoint's parameters must be validated the same way existing handlers validate theirs (existing project convention, not a new library) | | V6 Cryptography | No | Nothing in this phase touches crypto/secrets | ### Known Threat Patterns for this stack | Pattern | STRIDE | Standard Mitigation | |---------|--------|---------------------| | A new aggregate RPC endpoint accidentally exposing more fields than the requesting view needs (over-fetch through a "convenience" batch endpoint) | Information Disclosure | Shape the aggregate response to exactly what the calling screen renders — mirror the discipline already visible in existing typed RPC response interfaces in `rpc-client.ts` | | sessionStorage snapshot (D-08) persisting sensitive per-user data longer than intended | Information Disclosure | Already mitigated by the existing `persist: false` opt-out for large/sensitive payloads (D-08); when converting a new view, explicitly decide persist:true/false rather than defaulting blindly | No new authentication, authorization, or cryptographic surfaces are introduced by this phase — the security review here is narrow by design, matching the phase's actual scope. ## Sources ### Primary (HIGH confidence) - `neode-ui/src/composables/useCachedResource.ts`, `neode-ui/src/stores/resources.ts`, `neode-ui/src/api/rpc-client.ts`, `neode-ui/src/App.vue`, `neode-ui/src/views/Dashboard.vue`, `neode-ui/src/views/dashboard/useRouteTransitions.ts`, `neode-ui/src/router/index.ts`, `neode-ui/src/views/{Apps,Mesh,Cloud,Server,AppDetails,ContainerAppDetails}.vue`, `neode-ui/package.json`, `neode-ui/vitest.config.ts`, `apps/aiui/manifest.yml`, `scripts/setup-aiui-server.sh` — all read directly this session. - [RouterView slot | Vue Router](https://router.vuejs.org/guide/advanced/router-view-slot) — confirms the scoped-slot KeepAlive/Transition pattern is the only supported form in Vue Router 4. - [KeepAlive | Vue.js](https://vuejs.org/guide/built-ins/keep-alive) — `include`/`exclude` name matching, `max` LRU eviction, `activated`/`deactivated` hook semantics. ### Secondary (MEDIUM confidence) - [KeepAlive include/exclude parameters do not work as expected for asynchronously loaded router views · Issue #11764 · vuejs/core](https://github.com/vuejs/core/issues/11764) — WebFetch-summarized; describes the bug and states it was fixed by extracting the name from `__asyncResolved` — the exact patch-version boundary was not independently cross-verified against a changelog this session (see Assumption A1). - WebSearch results on `onActivated`/`onDeactivated` being a safe no-op outside `` (multiple Vue lifecycle-hook explainer articles, cross-checked against the official Vue lifecycle-hooks API reference). ### Tertiary (LOW confidence) - None — no unverified WebSearch-only claims were used to make a prescriptive recommendation in this document; the one external claim with residual uncertainty (A1) is explicitly flagged with a manual-verification mitigation rather than presented as fact. ## Metadata **Confidence breakdown:** - Standard stack: HIGH — no new libraries; every primitive cited was read directly from the codebase. - Architecture: HIGH — the KeepAlive insertion point, the `onActivated` gap, and the serial-waterfall example were all found by direct code inspection, not inferred. - Pitfalls: HIGH for pitfalls 1–7 (code-grounded); MEDIUM for the exact Vue patch-version boundary in Pitfall 1 (external GitHub issue, not independently changelog-verified). - AIUI ride-along (D-14): LOW — genuinely blocked pending source-location confirmation; flagged as Open Question 1, not glossed over. **Research date:** 2026-07-30 **Valid until:** 2026-08-13 (30 days is generous for a fast-moving frontend area; if the AIUI repo location or the Vue KeepAlive patch status changes, re-verify before relying on this doc)