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 |
|
|
|
|
|
|
|
|
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.tsgainedclearAll(), wired intoauth.ts'slogout()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 (catalogpackage.versions) is now a keyed, long-TTL cached resource; the other calls on this screen (getCurrentApp(), the bitcoin-prunefetch()) were confirmed not to need conversion.OpenWrtGateway.vueconverted from an unconditional every-mount refetch to a TTL-gated cache, and a real bug this exposed —loadingtreating 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 existingcloud.tsper-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
- Task 1: Cache lifetime — purge every cached resource on logout -
f44b8ac7(feat) - Task 2: App detail screens open instantly on repeat visits -
7c6c487a(feat) - Task 3: The remaining findings-named secondary screens -
ec89901f(feat)
Plan metadata: (this commit)
Files Created/Modified
neode-ui/src/stores/resources.ts— addedclearAll()and agenerationcounter guard against post-purge in-flight writesneode-ui/src/stores/auth.ts—logout()callsuseResourcesStore().clearAll()unconditionallyneode-ui/src/stores/__tests__/resourcesClear.test.ts— new; covers allclearAll()/logout behaviorsneode-ui/src/views/AppDetails.vue— bitcoin-sync + credentials converted to keyeduseCachedResource; invalidate on stop/restart/uninstallneode-ui/src/views/MarketplaceAppDetails.vue— catalog versions converted to a keyeduseCachedResourceneode-ui/src/views/server/OpenWrtGateway.vue— router status converted from raw-store always-refetch to TTL-gateduseCachedResource; fixed theloading/refreshingconflation bugneode-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:
- Single consumer. Only
CloudFolder.vuecallscloudStore.navigate()/readscloudStore.currentPath/sortedItemsfor the file-listing role (Cloud.vueuses a separatepeersResource/countsResourcepair 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. - 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 insidenavigate()itself before firingfileBrowserClient.listDirectory().cloud.tsis not in this plan'sfiles_modified(onlyCloudFolder.vueis), so that change is out of scope here. - The alternatives were worse than the status quo. Wrapping the view's own reads in a fresh
useCachedResourcecall inside the path-changewatch()callback (needed because the key is dynamic, one per path) would call the composable outside a reliably-active effect scope —onScopeDispose(which registers thewindow.addEventListener('focus', ...)cleanup) only fires whengetCurrentScope()returns non-null, which awatchcallback invoked via the reactivity scheduler doesn't reliably provide. That risks afocus-listener leak per folder visited for the life of the mount. The other alternative — duplicatingcloud.ts'spathCachelogic directly inside the view — would fragment the single source of truthCloudToolbar(breadcrumbs) andFileGrid(items) already read fromcloudStore. - Measured evidence supports leaving it.
02-PERF-BASELINE.json/02-FINDINGS.mdrecordCloudFolder's revisit RPC count as0withremounted: 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 awatch. Confirmed by readingDashboardRouterView.vue:<component :is="Component" :key="route.path" />— sinceroute.pathforapps/:idincludes 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 nowatch-driven re-key was needed for AppDetails or MarketplaceAppDetails.server.openwrt-statushas no item id in its key. The route (server/openwrt) has no:idparam 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.vueverdict reconfirmed: fully unreachable (per02-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.vueprints 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 theentries/inflight/revalidators/invalidateTimersmaps, but an already-in-flightrefresh()call captured its own reference to the (now-detached) entry object beforeclearAll()ran. When that fetch resolved afterward, itswriteSnapshot()call still executed unconditionally, writing a fresh sessionStorage entry for a key that had just been purged. - Fix: Added a
generationcounter, incremented byclearAll().refresh()capturesstartGenerationat call time and checksgeneration !== startGenerationbefore 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:
loadingtreatedloadState === '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:
loadingnow only blocks onrouterResource.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: falseresources (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 onePiniainstance 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 bothAppDetails.vueandMarketplaceAppDetails.vue) throws "Need to install withapp.usefunction" without a mounted i18n plugin — resolved by mockingvue-i18ndirectly (useI18n: () => ({ t: (key) => key })), matching the existing in-repo convention inMarketplaceRefresh.test.ts.
User Setup Required
None - no external service configuration required.
Next Phase Readiness
resources.ts'sclearAll()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-statuskey family and the "gate.refresh()ondata === null || isStale" pattern established here (rather than the composable's ownimmediateauto-refresh) is reusable for future per-item secondary-screen conversions — seeAppDetails.vue/OpenWrtGateway.vuefor 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 auseCachedResourceconversion would not fix it. - Blocker/concern carried forward:
cloud.ts'snavigate()needs a TTL gate to fully satisfy "no new RPC within the TTL" forCloudFolder.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 touchescloud.ts. - Correction carried forward:
PeerFiles.vuedoes not already useuseCachedResource(it uses the rawresourcesstore directly, correctly per-item-keyed) and shares the same "refreshing hides content" pattern this plan fixed inOpenWrtGateway.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.