diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 374f03f5..49451943 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -73,7 +73,7 @@ Plans: 3. Secondary screens open without a blocking full reload; repeat visits are instant 4. The fixes are verified on real node hardware (not just the dev box) — the sluggishness the user reported is gone on-device -**Plans**: 3/8 plans executed +**Plans**: 4/8 plans executed Plans: **Wave 1** @@ -87,7 +87,7 @@ Plans: **Wave 3** *(blocked on Wave 2 completion)* -- [ ] 02-04-PLAN.md — Keep every main tab alive safely: lifecycle audit + full registration (PERF-02) +- [x] 02-04-PLAN.md — Keep every main tab alive safely: lifecycle audit + full registration (PERF-02) **Wave 4** *(blocked on Wave 3 completion)* @@ -196,7 +196,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 1. Federation & Mesh Hardening | 0/10 | Planned | - | -| 2. UI Performance | 3/8 | In Progress| | +| 2. UI Performance | 4/8 | In Progress| | | 3. Multinode Verification Pass | 0/TBD | Not started | - | | 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - | | 5. Registry-Distributed Manifests | 0/TBD | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 7d635789..3bbf15e9 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,15 +5,15 @@ milestone_name: milestone current_phase: 02 current_phase_name: ui-performance status: executing -stopped_at: Completed 02-02-PLAN.md -last_updated: "2026-07-30T13:20:35.538Z" +stopped_at: Completed 02-04-PLAN.md +last_updated: "2026-07-30T19:29:23.802Z" last_activity: 2026-07-30 last_activity_desc: Phase 02 execution started progress: total_phases: 8 completed_phases: 0 total_plans: 18 - completed_plans: 3 + completed_plans: 4 percent: 0 --- @@ -29,11 +29,11 @@ See: .planning/PROJECT.md (updated 2026-07-29) ## Current Position Phase: 02 (ui-performance) — EXECUTING -Plan: 3 of 8 +Plan: 4 of 8 Status: Ready to execute Last activity: 2026-07-30 — Phase 02 execution started -Progress: [██░░░░░░░░] 17% +Progress: [██░░░░░░░░] 22% ## Performance Metrics @@ -55,6 +55,7 @@ Progress: [██░░░░░░░░] 17% | Phase 02 P01 | 100min | 3 tasks | 5 files | | Phase 02 P03 | 45min | 3 tasks | 5 files | | Phase 02 P02 | 105min | 3 tasks | 11 files | +| Phase 02 P04 | 150min | 3 tasks | 12 files | ## Accumulated Context @@ -84,6 +85,10 @@ Decisions are logged in PROJECT.md (10 locked ADRs in the `` block + - [Phase ?]: 02-02: HARD RULE for rest of Phase 02 — perf work must be visually invisible; verify against the real dev preview before considering a checkpoint satisfied - [Phase ?]: 02-02: app-catalog persist:true ttl 300000ms; bitcoin.prune-status persist:true ttl 30000ms — both explicit per T-02-01, no default relied on - [Phase ?]: 02-02: PERF-02 reverted to Pending/In-Progress in REQUIREMENTS.md after an automated mark-complete run — PERF-02 also spans 02-04..02-07 (extending KeepAlive caching to every remaining main tab); this plan proves the architecture on the tracer tab only +- [Phase ?]: 02-04: KEEP_ALIVE_PATHS widened to every audited main tab (10 paths) derived from TAB_ORDER + /dashboard/discover; /dashboard/settings deliberately withheld — its child sections (SystemDangerZone reboot poll, several onMounted-only fetches) were never audited by this plan +- [Phase ?]: 02-04: onActivated is a documented no-op outside a KeepAlive boundary — every arm function now runs from both onMounted and onActivated (fresh-mount guards on Home/Web5/Mesh/Server avoid doubling first-load RPC cost); caught by CloudPeersRefresh.test.ts +- [Phase ?]: 02-04: useCachedResource.ts's onActivated no longer eagerly force-loads a never-fetched immediate:false resource, so tab-gated lazy data (Cloud.vue Paid Files/My Files) isn't force-loaded merely by its view entering the KeepAlive cache +- [Phase ?]: 02-04: AIUI blank-screen-and-loading symptom reported at Task 3 checkpoint diagnosed as pre-existing (local mock-backend dev mode sets VITE_AIUI_URL=http://localhost:5173 unconditionally with no AIUI repo checked out) — not a regression, left for 02-07 (Chat/AIUI) to address ### Pending Todos @@ -118,6 +123,6 @@ None yet. ## Session Continuity -Last session: 2026-07-30T13:19:36.903Z -Stopped at: Completed 02-02-PLAN.md +Last session: 2026-07-30T19:29:12.322Z +Stopped at: Completed 02-04-PLAN.md Resume file: None diff --git a/.planning/phases/02-ui-performance/02-04-SUMMARY.md b/.planning/phases/02-ui-performance/02-04-SUMMARY.md new file mode 100644 index 00000000..b0959685 --- /dev/null +++ b/.planning/phases/02-ui-performance/02-04-SUMMARY.md @@ -0,0 +1,266 @@ +--- +phase: 02-ui-performance +plan: 04 +subsystem: ui +tags: [vue, keepalive, vue-router, activate-deactivate, useCachedResource, aiui] + +# Dependency graph +requires: + - phase: 02-ui-performance/02-02 + provides: "Route-path KeepAlive classifier (shouldKeepAlive/KEEP_ALIVE_PATHS/KEEP_ALIVE_MAX), DashboardRouterView.vue KeepAlive host with statically-named per-route wrapper components, the onActivated reactivation fix in useCachedResource.ts, RefreshIndicator.vue, and the onMounted/onActivated/onDeactivated side-effect audit convention" +provides: + - "Every main tab's timers, subscriptions and window listeners audited and placed for an activate/deactivate lifecycle (Home, Chat, Web5, Cloud, Server, Mesh, Apps, Discover)" + - "KEEP_ALIVE_PATHS widened from the tracer's single seed path to the full TAB_ORDER-derived set (10 paths: every main tab except /dashboard/settings) plus /dashboard/discover" + - "useCachedResource.ts fix: onActivated no longer eagerly force-loads an immediate:false (tab-gated lazy) resource that has never been explicitly fetched" + - "Discover.vue routed onto the same shared 'app-catalog'/'bitcoin.prune-status' cache keys Marketplace.vue introduced in 02-02" +affects: [02-05, 02-06, 02-07, 02-08] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Three-bucket side-effect classification (once-per-session/every-entry/only-while-visible) applied consistently across every main-tab view, with idempotent arm/disarm functions" + - "Dual-registration pattern: every arm function is called from BOTH onMounted and onActivated, because onActivated is a documented no-op outside a boundary — a bare (non-KeepAlive) mount must not silently skip a view's timers/subscriptions/listeners. Fresh-mount guard flags (Home/Web5/Mesh/Server) prevent the harmless-but-avoidable double-fire this causes on a KeepAlive-wrapped view's very first activation for the heavier loaders" + - "useCachedResource's onActivated staleness check now distinguishes 'never explicitly requested' (immediate:false, fetchedAt still null) from 'stale, already loaded once' — only the latter auto-revalidates on reactivation, so a tab-gated lazy resource isn't force-loaded merely by its owning view entering the KeepAlive cache" + +key-files: + created: + - neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts + modified: + - neode-ui/src/views/Home.vue + - neode-ui/src/views/Chat.vue + - neode-ui/src/views/web5/Web5.vue + - neode-ui/src/views/Cloud.vue + - neode-ui/src/views/Server.vue + - neode-ui/src/views/Mesh.vue + - neode-ui/src/views/Apps.vue + - neode-ui/src/views/Discover.vue + - neode-ui/src/views/dashboard/keepAliveRoutes.ts + - neode-ui/src/composables/useCachedResource.ts + - neode-ui/src/composables/__tests__/useCachedResource.test.ts + +key-decisions: + - "/dashboard/settings withheld from KEEP_ALIVE_PATHS despite being in TAB_ORDER: Settings.vue's child sections were never in this plan's file scope, and a grep found real un-audited side effects (SystemDangerZone.vue's reboot poll/elapsed intervals; one-shot onMounted-only fetches in VpnStatusSection.vue/KioskDisplaySection.vue/TransportPrefsCard.vue/ClaudeAuthSection.vue) that would misbehave under KeepAlive. Registering it without auditing those would ship the exact bug this plan exists to prevent." + - "Every other TAB_ORDER path (Mesh, Chat included) stays registered per the plan's literal exclusion rule: only a measured 'already fast'+Remounted:false excludes a path, and neither Mesh nor Chat has that (both are 02-FINDINGS.md 'unmeasured', not 'already fast')." + - "Discover.vue's catalog fetcher keeps its own dynamic-catalog-first behavior (fetchAppCatalog() with a curated-list fallback) rather than being flattened to Marketplace.vue's simpler getCuratedAppList()-only fetcher — both are valid producers of the shared 'app-catalog' cache key; dropping Discover's fetcher would have silently lost the dynamic-catalog/featured-banner capability." + - "useCachedResource.ts's onActivated guard change (skip auto-revalidation for a never-fetched immediate:false resource) applies to every current and future consumer, not just Cloud.vue/Server.vue — verified safe because Marketplace.vue's own resources (the only ones already inside a KeepAlive boundary before this plan) are both immediate:true (default), so this plan's approved 02-02 checkpoint behavior is unaffected." + - "Every dual-registered (onMounted + onActivated) arm function is idempotent by construction (clear/remove-then-set), confirmed safe by re-running the full existing test suite after the fix — CloudPeersRefresh.test.ts (which mounts Cloud.vue bare, outside any KeepAlive) caught the initial regression where onActivated-only logic silently never ran outside a KeepAlive boundary." + +requirements-completed: [] +requirements-note: "PERF-02 is NOT marked complete in REQUIREMENTS.md despite being this plan's sole requirements entry — PERF-02 also spans 02-05, 02-06 and 02-07, which still extend the KeepAlive/cache architecture to Mesh's fetch groups, Server/Home's data layer, and Chat/AIUI. This plan delivers the full lifecycle-audit + registration-widening layer only, per the same precedent 02-02/02-03 set for PERF-02/PERF-03." + +coverage: + - id: D1 + description: "Every audited main tab (Home, Chat, Web5, Cloud, Server, Mesh, Apps, Discover) has its timers/subscriptions/listeners placed into once-per-session, every-entry, or only-while-visible buckets, each idempotent and safe under both a bare mount and a KeepAlive-wrapped mount" + requirement: PERF-02 + verification: + - kind: unit + ref: "neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts" + status: pass + - kind: unit + ref: "neode-ui/src/composables/__tests__/useCachedResource.test.ts (lazy-resource-under-activation case)" + status: pass + human_judgment: false + - id: D2 + description: "KEEP_ALIVE_PATHS widened to the full TAB_ORDER-derived set (minus /dashboard/settings) plus /dashboard/discover; shouldKeepAlive stays exact-match (no secondary screen slips into the cache); eviction proven at KEEP_ALIVE_MAX+2 distinct registered paths" + requirement: PERF-02 + verification: + - kind: unit + ref: "neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts (keepAliveRoutes describe block)" + status: pass + human_judgment: false + - id: D3 + description: "Every main tab feels instant on revisit with no off-screen drain, wallet freshness on Home re-entry, Chat's AIUI panel staying loaded across a tab switch, Apps not showing a spurious connection error, secondary screens still remounting as before, and eviction observed at the 6-tab cap — verified on the real :8101 dev preview against archi-dev-box" + requirement: PERF-02 + verification: + - kind: manual_procedural + ref: "Task 3 checkpoint:human-verify — approved on all steps except a pre-existing AIUI dev-mode gap (see Known Issues)" + status: pass + human_judgment: true + rationale: "Visual/perceptual verification (instant paint, no stale margins/animations, wallet freshness timing) is inherently a human judgment call, consistent with 02-02's precedent for this same class of checkpoint." + +duration: ~150min +completed: 2026-07-30 +status: complete +--- + +# Phase 02 Plan 04: Main-Tab Lifecycle Audit + Full KeepAlive Registration Summary + +**Every main tab's onMounted-only side effects reclassified into once-per-session / every-entry / only-while-visible buckets and made idempotent under both a bare mount and a KeepAlive-wrapped mount, then KEEP_ALIVE_PATHS widened from the 02-02 tracer's single path to the full audited set (10 of 11 TAB_ORDER+discover paths — Settings withheld pending its own audit)** + +## Performance + +- **Duration:** ~150 min (including one checkpoint round-trip for a pre-existing AIUI dev-mode gap, diagnosed and confirmed out of scope) +- **Started:** 2026-07-30 (session start) +- **Completed:** 2026-07-30T19:26:00Z +- **Tasks:** 3 (Task 1 auto/tdd, Task 2 auto/tdd, Task 3 checkpoint:human-verify) +- **Files modified:** 11 (1 new test file, 10 modified) + +## Accomplishments + +- **Home.vue** — `systemStatsInterval` (10s), `walletRefreshInterval` (30s), the `wsClient` wallet-push subscription and its debounce timer now follow activate/deactivate with an immediate re-sync on entry (a resumed Home never shows a frozen wallet balance); `hydrateWalletSnapshot`/`checkUpdateStatus`/cloud-usage read stay once-per-session. +- **Chat.vue** — the `window` `message` listener and `ContextBroker` follow activate/deactivate; `aiuiConnected` is deliberately never reset on deactivate since the iframe's one-time `ready` postMessage won't resend on re-entry. +- **Web5.vue** — the six child-component data loaders (confirmed none use `useCachedResource` internally) and the 30s LND force-refresh interval move to activate/deactivate; the DID lookup and intro-stagger flag stay once-per-session. +- **Cloud.vue** — the per-peer transport/reachability warm-cache (`loadPeerFiles`, plus `loadCounts`/`loadPeers`) re-runs every entry, since it's the one path here that bypasses `useCachedResource` and would otherwise render stale peer-reachability data once cached (T-02-13). +- **Server.vue** — the previously module-scope-armed 15s VPN poll interval (which used to run forever regardless of tab visibility once anything wrapped this view in KeepAlive) now follows activate/deactivate with an immediate tick on entry; `loadDiskStatus` becomes every-entry. +- **Mesh.vue** — the entire live-communications surface (four window/document listeners, the 5s status/peers/messages poll, the 15s Archipelago-channel poll, the ws peer-push subscription, and the six-way federation/self/contacts refresh) follows activate/deactivate. A share-to-mesh handoff delivered via direct navigation (not the same-page custom event) is now correctly picked up on every activation, not just the first mount — a real gap that would have appeared the moment Mesh joined the instance cache. +- **Apps.vue** — the 15s "unable to connect" timer is now an entry-scoped guard (re-armed on activation, cleared on exit) and resets `connectionError` on entry so a since-reconnected node doesn't show a stale error instantly. +- **Discover.vue** — `loadCommunityMarketplace`/`loadBitcoinPruneStatus` now resolve against the same shared `app-catalog`/`bitcoin.prune-status` cache keys Marketplace.vue introduced in 02-02, with `RefreshIndicator` wired to the shared resource's `loadState`. +- **Fleet.vue** — confirmed no lifecycle side effects (grep for the five tokens found none); left unchanged, registered as-is. +- **keepAliveRoutes.ts** — `KEEP_ALIVE_PATHS` now derives from `TAB_ORDER` (single source of truth) plus `/dashboard/discover`, withholding `/dashboard/settings` for an unaudited-risk reason recorded in-file. +- **useCachedResource.ts** — a real bug found during the audit: `onActivated`'s staleness check treated a never-fetched `immediate:false` resource as stale, which would have eagerly force-loaded Cloud.vue's tab-gated Paid Files / My Files walk the moment Cloud.vue joined the instance cache. Fixed to only auto-revalidate a resource that has been explicitly fetched at least once. +- **Bare-mount regression caught and fixed** — my first pass moved several views' `onMounted`-only logic entirely into `onActivated`. `CloudPeersRefresh.test.ts` (which mounts `Cloud.vue` directly, no KeepAlive) caught that `onActivated` is a documented no-op outside a KeepAlive boundary. Fixed by calling every arm function from both `onMounted` and `onActivated`, with fresh-mount guard flags on the heavier views (Home/Web5/Mesh/Server) to avoid doubling their first-load network cost. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Timers, subscriptions and listeners follow activation, not mount** — `f177a505` (feat, tdd) +2. **Task 2: One-shot flags, entry timers, and widening the registration set** — `03a3e4e0` (feat, tdd) +3. **Task 3: Walk every main tab and confirm instant revisits with no off-screen drain** — checkpoint:human-verify, approved on all steps except one pre-existing dev-mode artifact (see Known Issues below); no code change required for it, per the diagnosis. + +**Plan metadata:** (this commit) + +_Note: both tasks are TDD tasks; tests were written and made to pass within each task's own commit, per this repo's established single-commit-per-task convention (see 02-01/02-02/02-03 history)._ + +## Files Created/Modified + +- `neode-ui/src/views/Home.vue` — wallet/stats polling, ws subscription follow activate/deactivate; once-per-session hydrate/update-check/cloud-usage +- `neode-ui/src/views/Chat.vue` — window listener + ContextBroker follow activate/deactivate +- `neode-ui/src/views/web5/Web5.vue` — six child-loaders + wallet poll follow activate/deactivate; DID lookup stays once-per-session +- `neode-ui/src/views/Cloud.vue` — counts/peers/peer-files warm-cache re-runs every entry +- `neode-ui/src/views/Server.vue` — VPN poll interval follows activate/deactivate; loadDiskStatus every-entry +- `neode-ui/src/views/Mesh.vue` — full live-communications lifecycle (listeners, two poll intervals, ws subscription, six-way refresh, deep-link handling) follows activate/deactivate +- `neode-ui/src/views/Apps.vue` — connection-timeout timer is now entry-scoped +- `neode-ui/src/views/Discover.vue` — catalog/prune-status routed onto Marketplace.vue's shared cache keys; RefreshIndicator added +- `neode-ui/src/views/dashboard/keepAliveRoutes.ts` — `KEEP_ALIVE_PATHS` widened, derived from `TAB_ORDER` +- `neode-ui/src/composables/useCachedResource.ts` — `onActivated` no longer eagerly force-loads a never-fetched `immediate:false` resource +- `neode-ui/src/composables/__tests__/useCachedResource.test.ts` — new test for the above fix +- `neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts` — new; covers all ten Task 1/Task 2 behaviors + +## Per-View Side-Effect Table + +Bucket key: **S** = once-per-session (onMounted only) · **E** = every-entry (onActivated, immediate on entry) · **V** = only-while-visible (armed onActivated, torn down onDeactivated) + +| View | Side effect | Bucket | +|---|---|---| +| Home.vue | `hydrateWalletSnapshot()` | S | +| Home.vue | `checkUpdateStatus()` | S | +| Home.vue | Cloud usage read (`fileBrowserClient.getUsage()`) | S | +| Home.vue | `systemStatsInterval` (10s `loadSystemStats`) | V | +| Home.vue | `walletRefreshInterval` (30s `loadWeb5Status`) | V | +| Home.vue | `wsClient.subscribe` (wallet push) + debounce timer | V | +| Chat.vue | `window` `message` listener (`onAiuiMessage`) | V | +| Chat.vue | `ContextBroker` | V | +| Chat.vue | `aiuiConnected` flag | Not reset on deactivate (special case — see plan text) | +| Web5.vue | `web5AnimationDone` intro flag | S | +| Web5.vue | `rpcClient.getNodeDid()` | S | +| Web5.vue | `loadPeers`/`loadReceivedMessages`/`loadConnectionRequests`/`loadIdentities`/`loadVisibility`/`loadNostrRelays`/`detectHardwareWallets` | E | +| Web5.vue | `walletRefreshInterval` (30s `lndInfoRes.refresh()`) | V | +| Cloud.vue | `loadCounts`/`loadPeers`/`loadPeerFiles` (peer transport/reachability warm-cache) | E (all internally staleness-gated or inflight-deduped, so no extra RPC when fresh) | +| Server.vue | `checkTorStatus`/`loadNetworkData`/`loadInterfaces`/`loadTorServices`/`loadVpnPeers`/`loadFipsSummary` | S (each resource self-heals via `useCachedResource`'s own `onActivated`) | +| Server.vue | `loadDiskStatus()` | E | +| Server.vue | `vpnPollInterval` (15s) | V | +| Mesh.vue | `window` resize, `document` pointerdown (menu + attach-menu), `archipelago:share-to-mesh` event, `visualViewport` resize/scroll | V | +| Mesh.vue | `loadPendingFromSession()` (share-to-mesh handoff) | E | +| Mesh.vue | `mesh.refreshAll()`/`transport.fetchStatus()`/`refreshFederationNodes()`/`refreshSelfOnion()`/`refreshSelfDid()`/`refreshContacts()` + deep-link peer/channel open | E | +| Mesh.vue | `archPollInterval` (15s `loadArchMessages`) | V | +| Mesh.vue | `pollInterval` (5s status/peers/messages/deadman/blockheaders + every-6th-tick contacts/federation/outbox) | V | +| Mesh.vue | `wsClient.subscribe` (peer push) | V | +| Apps.vue | `appsAnimationDone` intro flag | S | +| Apps.vue | `connectionTimer` (15s "unable to connect", `connectionError` reset on entry) | V (entry-scoped guard) | +| Discover.vue | `discoverAnimationDone` intro flag | S | +| Discover.vue | `catalogResource`/`pruneStatusResource` | S seed only — self-heals via `useCachedResource`'s own `onActivated` | +| Fleet.vue | (none found) | n/a — unchanged | + +## KEEP_ALIVE_PATHS — Final Contents + +**Registered (10 paths):** `/dashboard`, `/dashboard/apps`, `/dashboard/marketplace`, `/dashboard/cloud`, `/dashboard/mesh`, `/dashboard/server`, `/dashboard/web5`, `/dashboard/fleet`, `/dashboard/chat`, `/dashboard/discover` + +**Excluded (1 path):** `/dashboard/settings` — in `TAB_ORDER` but **not** registered. Reason: unaudited risk, not a measured "already fast" result (02-FINDINGS.md has no row for Settings at all). `Settings.vue`'s child sections were never in this plan's file scope, and a grep across `neode-ui/src/views/settings/*.vue` found real un-audited side effects — `SystemDangerZone.vue`'s reboot poll/elapsed intervals, and one-shot `onMounted`-only fetches in `VpnStatusSection.vue`, `KioskDisplaySection.vue`, `TransportPrefsCard.vue` and `ClaudeAuthSection.vue` — that would misbehave under KeepAlive exactly as this plan exists to prevent. Flagged for a future plan to audit before registering. + +No main tab was excluded for a measured "already fast, Remounted:false" reason — every 02-FINDINGS.md main-tab row was either `Remounted: true` or `unmeasured` (Mesh, Chat), and per the plan's own literal exclusion rule (only a measured `Remounted: false` excludes), both Mesh and Chat stay registered. + +## Decisions Made + +See `key-decisions` in frontmatter for the full list. Highlights: +- `/dashboard/settings` deliberately withheld (unaudited-risk, not "already fast") — see table above. +- `useCachedResource.ts`'s `onActivated` guard change is a shared-composable fix (affects every consumer), verified safe against the one pre-existing KeepAlive consumer (Marketplace.vue, both resources `immediate: true`). +- Discover.vue keeps its own catalog fetcher (dynamic-first, curated fallback) rather than being flattened to Marketplace.vue's simpler fetcher, since both are valid producers of the same shared cache key. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] `useCachedResource.ts`'s `onActivated` eagerly force-loaded a never-requested lazy resource** +- **Found during:** Task 1, auditing Cloud.vue's `immediate: false` resources (`paidResource`, `myFilesResource`) ahead of Task 2's registration widening +- **Issue:** `stale()` returns `true` for any never-fetched entry (`fetchedAt === null`), so `onActivated`'s bare `refreshIfStale()` would fire the fetch the instant a tab-gated lazy resource's owning view was first activated inside a KeepAlive — defeating "fetch on first use" (e.g. Cloud.vue's Paid Files tab data loading even though the user never opened that tab). +- **Fix:** `onActivated` now skips the auto-revalidate when `opts.immediate === false && entry.fetchedAt === null`; a resource that has been explicitly fetched at least once still revalidates staleness-gated on later reactivations. +- **Files modified:** `neode-ui/src/composables/useCachedResource.ts`, `neode-ui/src/composables/__tests__/useCachedResource.test.ts` (new test) +- **Verification:** New test asserts the lazy resource is not fetched merely by activation, but does revalidate once explicitly requested and later reactivated past its TTL. Full suite green. +- **Committed in:** `f177a505` (Task 1 commit) + +**2. [Rule 1 - Bug] Server.vue's `vpnPollInterval` was armed at module setup, not gated to visibility at all** +- **Found during:** Task 1, reading Server.vue's lifecycle regions per the plan's read_first +- **Issue:** The pre-existing `const vpnPollInterval = setInterval(...)` ran at component setup time (before `onMounted`), meaning once anything wrapped Server.vue in KeepAlive, this 15s poll would run forever regardless of tab visibility — exactly the CPU-drain class of bug T-02-03 exists to prevent. +- **Fix:** Converted to an idempotent `armVpnPoll()`/`disarmVpnPoll()` pair, armed on `onActivated` (with an immediate first tick) and torn down on `onDeactivated`; `onUnmounted` kept as a defensive teardown for the non-cached path. +- **Files modified:** `neode-ui/src/views/Server.vue` +- **Verification:** `keepAliveLifecycle.test.ts`'s real-view test mounts Server.vue inside a real `` and asserts `vpnStatus()` is not called while deactivated and is called once on reactivation. +- **Committed in:** `f177a505` (Task 1 commit) + +**3. [Rule 1 - Bug] Mesh.vue's share-to-mesh handoff would silently stop working after the first visit** +- **Found during:** Task 1, tracing `loadPendingFromSession()`'s only two triggers (the `onMounted` call and the same-page `archipelago:share-to-mesh` custom event) +- **Issue:** `App.vue` only dispatches the custom event when the user is *already* on `/mesh`; a direct `router.push('/mesh')` navigation (the only path for a share arriving from another screen) relied entirely on `onMounted`'s one-time read of the sessionStorage stash. Once Mesh.vue is kept alive, `onMounted` fires exactly once ever, so any share-to-mesh handoff after the very first Mesh visit would be silently dropped. +- **Fix:** `loadPendingFromSession()` moved into the every-entry `onActivated` bucket alongside the rest of Mesh.vue's live-data refresh. +- **Files modified:** `neode-ui/src/views/Mesh.vue` +- **Verification:** Full suite green; behavior traced against `App.vue`'s `onShareToMeshMessage` handler to confirm the direct-navigation path is the one this fixes. +- **Committed in:** `f177a505` (Task 1 commit) + +**4. [Rule 1 - Bug, caught by a pre-existing test] `onActivated`-only placement broke every view outside a KeepAlive boundary** +- **Found during:** Task 2, running the full suite after widening `KEEP_ALIVE_PATHS` — `CloudPeersRefresh.test.ts` (mounts `Cloud.vue` bare, no KeepAlive) failed +- **Issue:** My first pass moved several views' `onMounted`-only initialization entirely into `onActivated`, reasoning that "Vue fires `onActivated` on first mount too" — true only when the component already has a KeepAlive ancestor. Outside one, `onActivated` never fires at all, so Cloud.vue (and, by the same construction, Home.vue/Chat.vue/Apps.vue/Server.vue/Web5.vue/Mesh.vue) would silently skip every timer/subscription/listener/data-load it owns when mounted bare. +- **Fix:** Every arm function is now called from both `onMounted` and `onActivated`. Fresh-mount guard flags were added to Home.vue/Web5.vue/Mesh.vue/Server.vue (whose loaders have real RPC/CPU cost) so the redundant pass `onActivated` makes immediately after `onMounted` on a KeepAlive-wrapped first mount is skipped rather than doubling the first-load cost; Cloud.vue's dual call is left unguarded since its loaders are internally staleness-gated / inflight-deduped (confirmed genuinely free, not just low-cost) and Apps.vue/Chat.vue's dual call is trivial (a timer re-arm / listener re-add, no RPC). +- **Files modified:** `neode-ui/src/views/Home.vue`, `neode-ui/src/views/Chat.vue`, `neode-ui/src/views/web5/Web5.vue`, `neode-ui/src/views/Cloud.vue`, `neode-ui/src/views/Server.vue`, `neode-ui/src/views/Mesh.vue`, `neode-ui/src/views/Apps.vue` +- **Verification:** Full suite green (90 files / 740 tests), `npm run type-check` and `npm run build` both clean. +- **Committed in:** `f177a505` and `03a3e4e0` (both task commits — the fix landed across the same files each task touched) + +--- + +**Total deviations:** 4 auto-fixed (all Rule 1 — bugs directly implicated by this plan's own must_haves truths or caught by the existing test suite) +**Impact on plan:** All four were necessary corrections that stayed within files already in scope for their respective tasks. No scope creep. The bare-mount regression (deviation 4) is the most consequential — it's a general pattern risk that would have silently affected every view converted in this plan had `CloudPeersRefresh.test.ts` not existed to catch it. + +## Known Issues + +**AIUI panel shows a blank white screen with a persistent loading spinner in the local mock-backend dev mode (`:8100`) — pre-existing, NOT a regression from this plan.** + +- **Reported during:** Task 3 checkpoint, on the `:8101` dev preview session (this plan's own instance, pointed at archi-dev-box via `BACKEND_URL`) +- **Diagnosis performed:** + 1. Diffed `Chat.vue` against `f177a505^` (its state immediately before this plan's Task 1 commit) — the AIUI iframe `src` construction (`aiuiUrl` computed) and the `ContextBroker` instantiation are byte-identical in intent; the only change is that `armChatLive()` (listener + broker setup) now runs from `onActivated`/`onMounted` instead of `onMounted` alone. `ContextBroker.start()` only adds a passive `window` message listener — it sends nothing to the iframe and has no handshake that a double-invocation on first mount could leave half-completed. + 2. Traced `aiuiUrl`'s computation: in dev mode (not `PROD`, not `IS_DEMO`), it is **empty unless `VITE_AIUI_URL` is explicitly set** — in which case Chat.vue renders the empty "AI Assistant not configured" placeholder, not a blank iframe. A blank-iframe-with-spinner symptom therefore requires `VITE_AIUI_URL` to be set to an unreachable target. + 3. Found the source: `scripts/dev-start.sh`'s "Mock backend" menu option (the one that serves `:8100`) launches `VITE_AIUI_URL=http://localhost:5173 vite` unconditionally, alongside a best-effort `cd ../../AIUI && pnpm dev` that silently no-ops (`|| echo '[AIUI] Not found...'`) when the separate AIUI repo isn't checked out next to `neode-ui/`. Confirmed on this machine: no `AIUI` directory exists anywhere near the project root, and nothing listens on port 5173 (`lsof -ti:5173` empty, `curl` to `localhost:5173` connection-refused). + 4. This means the `:8100` mock-backend dev session points its AIUI iframe at a dead local port **regardless of any Chat.vue code change** — the iframe shows the browser's own blank error page, and since nothing ever posts a `ready` message, `aiuiConnected` never flips true and the loading overlay never clears. This reproduces identically against `f177a505^`'s Chat.vue. + 5. 02-FINDINGS.md (written in 02-01, before this plan existed) already flags Chat/AIUI as `unmeasured` with connection/handshake latency called out as a known rough edge on real hardware — corroborating this is a pre-existing gap in this area, not something introduced here. +- **Verdict:** Pre-existing artifact of the local "Mock backend" dev mode's AIUI wiring (missing sibling `AIUI` checkout), not a regression from this plan's commits. **Not fixed here** — the AIUI embed URL/connectivity work is explicitly owned by plan 02-07 (`02-AIUI-D14.md`, wave 4, not yet run); pulling it into 02-04 would be scope creep into another plan's file ownership. +- **Recommendation for 02-07:** confirm the AIUI dev-mode wiring assumption (does it expect a sibling `../../AIUI` checkout, or should `VITE_AIUI_URL` only be set when that dev server is confirmed running?) as part of its own scope. + +## Issues Encountered + +- The Task 3 checkpoint's first pass surfaced the AIUI dev-mode gap above; diagnosed and confirmed pre-existing/out-of-scope per the coordinator's explicit instruction, so the checkpoint is treated as fully approved (all other steps passed on the first attempt). + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- The full main-tab registration set (`KEEP_ALIVE_PATHS`) is now the actual production set 02-05/02-06/02-07 build on — no further widening needed from those plans. +- 02-05 (Mesh) and 02-06 (Server and Home) can proceed directly to converting the remaining fire-and-forget fetch groups to `useCachedResource`, since this plan already placed every lifecycle side effect correctly for that conversion to land safely under KeepAlive. +- 02-07 (Chat/AIUI) inherits the AIUI dev-mode gap noted above as a concrete finding to address as part of its own D-14 UX work. +- **Blocker/concern carried forward:** `/dashboard/settings` is not yet in the instance cache — a future plan should audit `Settings.vue`'s child sections (`SystemDangerZone.vue`, `VpnStatusSection.vue`, `KioskDisplaySection.vue`, `TransportPrefsCard.vue`, `ClaudeAuthSection.vue`) the way this plan audited the other eight tabs before registering it. +- No other blockers for 02-05/02-06/02-07. + +--- +*Phase: 02-ui-performance* +*Completed: 2026-07-30* + +## Self-Check: PASSED