Files
archy/.planning/phases/02-ui-performance/02-03-SUMMARY.md

23 KiB

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, requirements-completed, coverage, duration, completed, status
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions requirements-completed coverage duration completed status
02-ui-performance 03 ui
vue3
pinia
stale-while-revalidate
sessionStorage
useCachedResource
phase provides
02-ui-performance/02-01 02-FINDINGS.md's per-surface measured causes and Ranked Fix Order; the existing useCachedResource composable and resources.ts store this plan extends
resources.ts clearAll() — purges every cached resource (memory + sessionStorage) on logout, with a generation guard so an in-flight fetch from the ending session cannot repopulate the cache after it resolves
auth.ts logout() calls clearAll() unconditionally (success and failure paths)
AppDetails.vue and MarketplaceAppDetails.vue converted to per-item keyed useCachedResource
OpenWrtGateway.vue converted from an always-refetch raw-store read to a TTL-gated useCachedResource, fixing a loading/refreshing conflation bug that hid cached content behind a full skeleton on every mount
02-04-keepalive-lifecycle
02-08-verify
added patterns
Per-item cache key family: app-details:<dataset>:<routeId> — computed once at setup time, safe because DashboardRouterView.vue keys <component :is> by route.path, so an id change on these routes always fully remounts the component (confirmed by reading the template; no watch-driven re-key needed)
TTL-gated force-refresh wrapper: a mutable pendingParams closure variable lets an explicit force-refresh function (load()) reuse the same useCachedResource instance an onMounted staleness check gates, without re-keying or re-creating the hook
loading must exclude 'refreshing' from any state that blocks rendering the cached view — conflating them (pre-existing pattern in OpenWrtGateway.vue) hides already-rendered content behind a full loading skeleton on every background revalidation, defeating stale-while-revalidate's entire purpose
created modified
neode-ui/src/stores/__tests__/resourcesClear.test.ts
neode-ui/src/views/__tests__/secondaryScreenCache.test.ts
neode-ui/src/stores/resources.ts
neode-ui/src/stores/auth.ts
neode-ui/src/views/AppDetails.vue
neode-ui/src/views/MarketplaceAppDetails.vue
neode-ui/src/views/server/OpenWrtGateway.vue
CloudFolder.vue: left unchanged, not converted to useCachedResource. Its data flows through cloudStore's own hand-rolled per-path Map cache (cloud.ts), which already delivers instant paint-from-cache on revisit (0 RPC measured on revisit per 02-PERF-BASELINE.json) and correctly excludes file listings from sessionStorage (D-08 satisfied by construction, not by an explicit persist:false). A literal useCachedResource conversion that also adds TTL-gated no-refetch semantics requires cloudStore.navigate() itself to skip its RPC when the cached path is fresh — that change lives in cloud.ts, which is outside this plan's files_modified. Bolting a second, parallel cache onto the view without touching cloud.ts was rejected: it would either leak a subscription/focus-listener per folder visited (useCachedResource's disposal path only fires from an active effect scope, which a dynamic per-path key called from inside a watch callback doesn't reliably have) or duplicate cloud.ts's pathCache and fragment the single source of truth CloudToolbar/FileGrid/breadcrumbs already read from cloudStore. Flagged as a residual gap for a future plan to land inside cloud.ts's navigate().
OpenWrtGateway.vue's cache key has no item id — the route (server/openwrt) has no :id param and there is exactly one configured gateway per node, so a bare 'server.openwrt-status' key is correct and no per-item collision risk exists.
Wallet/send flow (SendBitcoinModal.vue via Home.vue) is named by 02-FINDINGS.md as owned by 02-03 (worst-ranked revisit at 2607ms) but does not appear in this plan's files_modified. Per Task 3's scope guard, this is reported as an unplanned-item gap rather than silently dropped or force-fitted into an out-of-scope file edit — see 'Unplanned-Item Gap' below.
PeerFiles.vue does NOT already consume useCachedResource as this plan's Task 3 read_first assumed — it uses the raw resources store directly (resources.entry/resources.refresh, same pattern OpenWrtGateway.vue used before this plan) with a correctly per-item key (cloud.peer-browse:<onion>). It also force-refetches unconditionally on every mount (no staleness gate) and conflates 'refreshing' with a blocking loading state, the same bug this plan just fixed in OpenWrtGateway.vue. Left untouched — out of files_modified scope — but flagged here as a correction to the plan's assumption and a candidate for the same TTL-gate + loading-state fix in a future plan.
ContainerAppDetails.vue: reconfirmed fully unreachable (zero grep matches, no importer, no route entry) per 02-FINDINGS.md's 'Corrections to Prior Research' section. Untouched, as required.
PERF-03 is NOT marked complete in REQUIREMENTS.md despite being this plan's sole `requirements:` entry — its own requirement text conditions completion on 'verified on real node hardware, not just the dev box', which is 02-08's on-device checkpoint:human-verify pass (02-08-PLAN.md also declares PERF-03 in its frontmatter). This plan delivers the code-level portion only; an earlier automated `requirements.mark-complete PERF-03` run was reverted after re-reading REQUIREMENTS.md's own text — do not re-mark it complete until 02-08 lands.
id description requirement verification human_judgment
D1 Logout purges every cached resource (memory + sessionStorage), including one that was in-flight when logout ran PERF-03
kind ref status
unit neode-ui/src/stores/__tests__/resourcesClear.test.ts pass
false
id description requirement verification human_judgment
D2 AppDetails.vue's bitcoin-sync and credentials data paint from a per-item cache on repeat visits, never cross items, and credentials stay memory-only PERF-03
kind ref status
unit neode-ui/src/views/__tests__/secondaryScreenCache.test.ts#AppDetails.vue — per-item cached resources (bitcoin sync + credentials) pass
false
id description requirement verification human_judgment
D3 MarketplaceAppDetails.vue's catalog version data is per-item cached (120s TTL) instead of refetched on every mount PERF-03
kind ref status
unit neode-ui/src/views/__tests__/secondaryScreenCache.test.ts#MarketplaceAppDetails.vue — per-item cached catalog versions pass
false
id description requirement verification human_judgment
D4 OpenWrtGateway.vue's router status paints from cache on repeat visits with no new RPC inside the TTL, and no longer hides cached content behind a loading skeleton during background revalidation PERF-03
kind ref status
unit neode-ui/src/views/__tests__/secondaryScreenCache.test.ts#OpenWrtGateway.vue — cached router status (no item id: one gateway per node) pass
false
id description verification human_judgment rationale
D5 CloudFolder.vue cache-placement decision and Wallet/send-flow scope gap are documented, not silently dropped
true This is a documentation/scope-judgment deliverable (why a screen was left as-is or reported as a gap), not a testable code behavior — a human should confirm the reasoning is sound before the next plan (02-08 verify, or a future cloud.ts follow-up) relies on it.
45min 2026-07-30 complete

Phase 02 Plan 03: Secondary Screen Caching Summary

Per-item keyed useCachedResource conversions for AppDetails, MarketplaceAppDetails, and OpenWrtGateway, plus a logout cache-purge with a generation guard against in-flight-fetch resurrection — CloudFolder's existing store-level cache is left in place with the reasoning recorded.

Performance

  • Duration: ~45 min
  • Tasks: 3
  • Files modified: 5 (2 new test files, 1 new + 2 modified store files, 3 modified view files)

Accomplishments

  • resources.ts gained clearAll(), wired into auth.ts's logout() on both the success and failure paths, so no cached payload (memory or sessionStorage) outlives a logout — including one that was mid-flight when logout ran (closed via a generation counter, a real gap the TDD tests caught).
  • AppDetails.vue's bitcoin-sync and credentials data are now keyed per app id (app-details:bitcoin-sync:<id>, app-details:credentials:<id>), each with an explicit TTL and persist choice; credentials is memory-only. Stop/restart/uninstall now invalidate the credentials cache so a stale healthy state can't outlive a destructive action.
  • MarketplaceAppDetails.vue's one non-confounded RPC (catalog package.versions) is now a keyed, long-TTL cached resource; the other calls on this screen (getCurrentApp(), the bitcoin-prune fetch()) were confirmed not to need conversion.
  • OpenWrtGateway.vue converted from an unconditional every-mount refetch to a TTL-gated cache, and a real bug this exposed — loading treating a background 'refreshing' revalidation the same as a blocking 'loading' state — is fixed, so cached content now actually paints instantly instead of flashing a skeleton on every visit.
  • CloudFolder.vue's cache-placement question was resolved: leave the existing cloud.ts per-path cache in place (already satisfies the plan's truths for this screen) rather than bolt on a redundant or leak-prone second cache confined to the view.

Task Commits

  1. Task 1: Cache lifetime — purge every cached resource on logout - f44b8ac7 (feat)
  2. Task 2: App detail screens open instantly on repeat visits - 7c6c487a (feat)
  3. Task 3: The remaining findings-named secondary screens - ec89901f (feat)

Plan metadata: (this commit)

Files Created/Modified

  • neode-ui/src/stores/resources.ts — added clearAll() and a generation counter guard against post-purge in-flight writes
  • neode-ui/src/stores/auth.tslogout() calls useResourcesStore().clearAll() unconditionally
  • neode-ui/src/stores/__tests__/resourcesClear.test.ts — new; covers all clearAll()/logout behaviors
  • neode-ui/src/views/AppDetails.vue — bitcoin-sync + credentials converted to keyed useCachedResource; invalidate on stop/restart/uninstall
  • neode-ui/src/views/MarketplaceAppDetails.vue — catalog versions converted to a keyed useCachedResource
  • neode-ui/src/views/server/OpenWrtGateway.vue — router status converted from raw-store always-refetch to TTL-gated useCachedResource; fixed the loading/refreshing conflation bug
  • neode-ui/src/views/__tests__/secondaryScreenCache.test.ts — new; covers all four converted resources' repeat-open/TTL/keep-last-value/concurrency behaviors

Cache Keys Introduced

Key File TTL Persist Reason
app-details:bitcoin-sync:<appId> AppDetails.vue 30 000 ms true (default) Non-sensitive numeric health/sync state; default TTL matches plan guidance for "install/health-state-shaped data"
app-details:credentials:<appId> AppDetails.vue 30 000 ms false Credential material (D-08 / T-02-01) — memory-only, never written to sessionStorage
app-details:versions:<appId> MarketplaceAppDetails.vue 120 000 ms true (default) Near-static catalog metadata (version list, deprecation/EOL flags) — no credential/DID/wallet/tx-history content, so a longer TTL than the 30s default is appropriate per plan guidance
server.openwrt-status OpenWrtGateway.vue 30 000 ms true (default) No item id — one gateway per node, fixed route with no :id param. Contains host/hostname/uptime/release/tollgate-config/wifi/wan status; none of it is credential/DID/wallet/tx-history material

Findings-Named Secondary Screens: Converted vs. Gap

Per 02-FINDINGS.md's Owning Plans table, five secondary/modal surfaces are named as owned by 02-03:

Surface Status Notes
AppDetails Converted (Task 2) bitcoin-sync + credentials keyed resources
MarketplaceAppDetails Converted (Task 2) catalog versions keyed resource
CloudFolder Decision recorded, left unchanged (Task 3) See "CloudFolder.vue Cache-Placement Decision" below
OpenWrtGateway Converted (Task 3) router status keyed resource, no item id
Wallet / send flow (SendBitcoinModal.vue via Home.vue) Unplanned-item gap Named by findings (worst-ranked revisit, 2607ms) but not in this plan's files_modified; Home.vue/SendBitcoinModal.vue were not touched. Per the plan's Task 3 scope guard, this is reported to the orchestrator as a gap rather than silently dropped or force-fitted into an out-of-scope edit. Measured cause per findings: the send modal fully remounts on every reopen (BaseModal's v-if) with zero RPC either time — the ~1.9s extra cost on reopen is pure client-side recompute (fee/balance/store re-subscription), not a data-cache problem, so this surface likely needs a different fix (client-side profiling / render-cost reduction) rather than a useCachedResource conversion. Recommend a follow-up task or plan scoped explicitly to Home.vue/SendBitcoinModal.vue.

CloudFolder.vue Cache-Placement Decision

CloudFolder.vue's file-listing data flows through cloudStore (src/stores/cloud.ts), driven by two watch() blocks rather than a mount hook. cloud.ts already implements its own hand-rolled per-path stale-while-revalidate cache (pathCache: Map<string, FileBrowserItem[]> in navigate()): a revisit to a previously-viewed path paints the cached listing synchronously while a background refresh runs underneath, and the listing is never written to sessionStorage (D-08 is satisfied by construction — this is a plain in-memory Map, not backed by resources.ts).

Decision: leave this mechanism in place; do not add a useCachedResource wrapper in CloudFolder.vue.

Reasoning:

  1. Single consumer. Only CloudFolder.vue calls cloudStore.navigate()/reads cloudStore.currentPath/sortedItems for the file-listing role (Cloud.vue uses a separate peersResource/countsResource pair for federation peers, an unrelated dataset). The plan's guidance to "put the cached resource behind the store action" when several views share a loader doesn't apply here.
  2. The one real gap — no TTL gate — lives in cloud.ts, not the view. cloudStore.navigate() always re-issues its RPC on every call, regardless of freshness (it just doesn't block rendering, since the cached listing paints first). Properly closing this gap means adding a TTL check inside navigate() itself before firing fileBrowserClient.listDirectory(). cloud.ts is not in this plan's files_modified (only CloudFolder.vue is), so that change is out of scope here.
  3. The alternatives were worse than the status quo. Wrapping the view's own reads in a fresh useCachedResource call inside the path-change watch() callback (needed because the key is dynamic, one per path) would call the composable outside a reliably-active effect scope — onScopeDispose (which registers the window.addEventListener('focus', ...) cleanup) only fires when getCurrentScope() returns non-null, which a watch callback invoked via the reactivity scheduler doesn't reliably provide. That risks a focus-listener leak per folder visited for the life of the mount. The other alternative — duplicating cloud.ts's pathCache logic directly inside the view — would fragment the single source of truth CloudToolbar (breadcrumbs) and FileGrid (items) already read from cloudStore.
  4. Measured evidence supports leaving it. 02-PERF-BASELINE.json/02-FINDINGS.md record CloudFolder's revisit RPC count as 0 with remounted: true — the current mechanism already delivers "paints instantly from cache, no new RPC" for the revisit case; the ~5s first-visit cost is a lazy route-chunk cold load, unrelated to data caching.

Flagged for follow-up: a future plan (or an extension of a cloud.ts-scoped plan) should add the TTL gate inside navigate() so a path visited within its TTL skips the RPC entirely, matching the letter of "no new RPC for the cached dataset" in addition to the spirit ("paints instantly, no blocking reload") this screen already satisfies.

Decisions Made

  • app-details:bitcoin-sync:<appId> / app-details:credentials:<appId> keys are computed once at setup time, not re-derived via a watch. Confirmed by reading DashboardRouterView.vue: <component :is="Component" :key="route.path" /> — since route.path for apps/:id includes the id itself, an id change on this route always produces a different key, which Vue treats as a full unmount/remount. This was an open assumption in the plan (02-03-PLAN.md's "Assumptions & Flagged Items"); it resolves to "always remounts," so no watch-driven re-key was needed for AppDetails or MarketplaceAppDetails.
  • server.openwrt-status has no item id in its key. The route (server/openwrt) has no :id param and there is exactly one configured router gateway per node — a bare key is correct; adding a fake per-node id would be scope creep with no isolation benefit.
  • ContainerAppDetails.vue verdict reconfirmed: fully unreachable (per 02-FINDINGS.md's "Corrections to Prior Research" — zero grep matches, no importer, no route entry). Untouched, as the plan requires; git diff --name-only HEAD -- neode-ui/src/views/ContainerAppDetails.vue prints nothing.

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] resources.ts's refresh() could repopulate memory/sessionStorage after clearAll()

  • Found during: Task 1, writing the TDD test for "a resolving fetch from the old session cannot repopulate the cache"
  • Issue: clearAll() cleared the entries/inflight/revalidators/invalidateTimers maps, but an already-in-flight refresh() call captured its own reference to the (now-detached) entry object before clearAll() ran. When that fetch resolved afterward, its writeSnapshot() call still executed unconditionally, writing a fresh sessionStorage entry for a key that had just been purged.
  • Fix: Added a generation counter, incremented by clearAll(). refresh() captures startGeneration at call time and checks generation !== startGeneration before writing to the entry or to sessionStorage, both on the success and error paths.
  • Files modified: neode-ui/src/stores/resources.ts
  • Verification: resourcesClear.test.ts's "drops in-flight bookkeeping..." test asserts no sessionStorage write survives a post-clearAll resolution.
  • Committed in: f44b8ac7 (Task 1 commit)

2. [Rule 1 - Bug] OpenWrtGateway.vue's loading computed hid cached content behind a full skeleton on every background revalidation

  • Found during: Task 3, writing the TTL-lapse repeat-open test — the cached hostname failed to appear on the first frame after a stale remount
  • Issue: loading treated loadState === 'refreshing' the same as 'loading', so any time a cached entry's status flipped to 'refreshing' (background revalidate in flight), the full loading skeleton rendered instead of the already-cached status panels — directly contradicting D-07's keep-last-value requirement and this plan's must_haves truth ("previous content stays on screen while exactly one background revalidation runs"). This was previously masked because the pre-existing code force-refetched unconditionally on every mount, so the skeleton showed on literally every visit regardless of cache freshness — a UX regression that predates this plan but only became visible/fixable once mount-time force-fetching was replaced with TTL gating.
  • Fix: loading now only blocks on routerResource.data.value === null && (loadState === 'loading' || loadState === 'idle') — a true first-load with no data at all. A background refresh ('refreshing') no longer hides the rendered status panels.
  • Files modified: neode-ui/src/views/server/OpenWrtGateway.vue
  • Verification: secondaryScreenCache.test.ts's "a repeat mount after the TTL lapses shows cached data on the first frame..." test for OpenWrtGateway.
  • Committed in: ec89901f (Task 3 commit)

Total deviations: 2 auto-fixed (both Rule 1 — bugs directly blocking this plan's must_haves truths) Impact on plan: Both fixes were necessary for the plan's core correctness guarantee (stale-while-revalidate actually keeping cached content visible); no scope creep — both stayed within the files already being converted.

Issues Encountered

  • Initial test-writing pass for AppDetails/MarketplaceAppDetails created a fresh Pinia instance per mount, which silently defeated the in-memory cache for persist: false resources (credentials) between a mount/unmount/remount pair within one test — the production app has a single, app-lifetime Pinia instance, so this was a test-authoring bug, not a product bug. Fixed by sharing one Pinia instance across the mount pairs within each test (matching how a real browsing session keeps the same Pinia across secondary-screen navigation).
  • Vue's useI18n() (used by both AppDetails.vue and MarketplaceAppDetails.vue) throws "Need to install with app.use function" without a mounted i18n plugin — resolved by mocking vue-i18n directly (useI18n: () => ({ t: (key) => key })), matching the existing in-repo convention in MarketplaceRefresh.test.ts.

User Setup Required

None - no external service configuration required.

Next Phase Readiness

  • resources.ts's clearAll() is available for any other plan that needs a full-cache purge (e.g. an identity-switch flow beyond logout).
  • The app-details:* / server.openwrt-status key family and the "gate .refresh() on data === null || isStale" pattern established here (rather than the composable's own immediate auto-refresh) is reusable for future per-item secondary-screen conversions — see AppDetails.vue/OpenWrtGateway.vue for the reference shape.
  • Blocker/concern carried forward: the Wallet/send-flow gap (Home.vue/SendBitcoinModal.vue, 2607ms revisit, zero RPC — pure client-side recompute cost) needs its own task or plan; it is not a data-caching problem and a useCachedResource conversion would not fix it.
  • Blocker/concern carried forward: cloud.ts's navigate() needs a TTL gate to fully satisfy "no new RPC within the TTL" for CloudFolder.vue; today it always re-fetches on every call (just without blocking the paint). Not urgent — the measured revisit cost is already near-zero — but noted for whichever plan next touches cloud.ts.
  • Correction carried forward: PeerFiles.vue does not already use useCachedResource (it uses the raw resources store directly, correctly per-item-keyed) and shares the same "refreshing hides content" pattern this plan fixed in OpenWrtGateway.vue. Worth a small follow-up fix when that file is next touched.

Phase: 02-ui-performance Completed: 2026-07-30

Self-Check: PASSED

All created/modified files verified present on disk; all three task commit hashes (f44b8ac7, 7c6c487a, ec89901f) verified in git history.