`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)