Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
---
|
||||
phase: 02-ui-performance
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["02-01"]
|
||||
files_modified:
|
||||
- neode-ui/src/composables/useCachedResource.ts
|
||||
- neode-ui/src/composables/__tests__/useCachedResource.test.ts
|
||||
- neode-ui/src/views/dashboard/keepAliveRoutes.ts
|
||||
- neode-ui/src/views/dashboard/DashboardRouterView.vue
|
||||
- neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts
|
||||
- neode-ui/src/views/dashboard/useRouteTransitions.ts
|
||||
- neode-ui/src/views/Dashboard.vue
|
||||
- neode-ui/src/components/RefreshIndicator.vue
|
||||
- neode-ui/src/views/Marketplace.vue
|
||||
autonomous: false
|
||||
requirements: [PERF-02]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Switching away from the tracer main tab and back renders its content with no spinner and no blank frame, from the surviving component instance"
|
||||
- "The tracer tab's component instance is reused across a tab round-trip — it mounts once per session, not once per visit"
|
||||
- "Returning to the tracer tab within the TTL issues no new RPC for its cached resource"
|
||||
- "Returning to the tracer tab after the TTL has lapsed issues exactly one background revalidation and keeps the previous content on screen while it runs (D-01)"
|
||||
- "While that background revalidation is in flight a subtle refresh indicator is visible, driven by loadState === 'refreshing' (D-05)"
|
||||
- "A failed background refresh leaves the last known content on screen and raises no toast (D-07)"
|
||||
- "A secondary screen reached from a tab's main page is not instance-cached — it mounts fresh each visit (D-04)"
|
||||
- "The number of cached view instances is capped, so visiting every main tab does not grow the instance cache without bound (D-03)"
|
||||
- "Scroll position within a main tab is restored on return rather than reset to the top"
|
||||
- statement: "The route transition animations that played before the KeepAlive restructure still play afterwards, with the same names for the same navigations"
|
||||
verification: backstop
|
||||
prohibitions:
|
||||
- "MUST NOT present cached data as live — a money- or liveness-critical surface (wallet balance, incoming payment, mesh peer reachability, app install or health state) must never render from cache without a visible refresh signal and an in-flight revalidation"
|
||||
- "MUST NOT persist wallet balances, transaction history, credentials, DIDs, seed or identity material, or peer identity payloads to sessionStorage"
|
||||
- "MUST NOT achieve perceived speed by removing behavior or hiding state — no suppressing the refresh indicator, no dropping a fetch the surface needs, no disabling a feature to win the metric"
|
||||
artifacts:
|
||||
- path: "neode-ui/src/views/dashboard/keepAliveRoutes.ts"
|
||||
provides: "The single source of truth for which routes are instance-cached, plus the instance cap"
|
||||
exports: ["shouldKeepAlive", "KEEP_ALIVE_PATHS", "KEEP_ALIVE_MAX"]
|
||||
- path: "neode-ui/src/views/dashboard/DashboardRouterView.vue"
|
||||
provides: "The extracted, testable KeepAlive host — the nested RouterView that actually remounts on tab switch"
|
||||
- path: "neode-ui/src/components/RefreshIndicator.vue"
|
||||
provides: "Subtle background-refresh indicator driven by a loadState prop (D-05)"
|
||||
- path: "neode-ui/src/composables/__tests__/useCachedResource.test.ts"
|
||||
provides: "Coverage for the onActivated revalidation and the preserved sticky-ready / keep-last-value semantics"
|
||||
- path: "neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts"
|
||||
provides: "Proof that an included route's instance survives a round-trip and an excluded route's does not"
|
||||
key_links:
|
||||
- from: "neode-ui/src/views/dashboard/DashboardRouterView.vue"
|
||||
to: "neode-ui/src/views/dashboard/keepAliveRoutes.ts"
|
||||
via: "calls shouldKeepAlive(route) to decide which branch renders the view"
|
||||
pattern: "shouldKeepAlive"
|
||||
- from: "neode-ui/src/composables/useCachedResource.ts"
|
||||
to: "vue onActivated"
|
||||
via: "reactivation triggers refreshIfStale so a KeepAlive'd tab still background-refreshes"
|
||||
pattern: "onActivated"
|
||||
- from: "neode-ui/src/views/Marketplace.vue"
|
||||
to: "neode-ui/src/composables/useCachedResource.ts"
|
||||
via: "the tracer tab's catalog and status fetches move onto keyed cached resources"
|
||||
pattern: "useCachedResource"
|
||||
- from: "neode-ui/src/views/Dashboard.vue"
|
||||
to: "neode-ui/src/views/dashboard/DashboardRouterView.vue"
|
||||
via: "Dashboard renders the extracted host in place of its inline nested RouterView"
|
||||
pattern: "DashboardRouterView"
|
||||
---
|
||||
|
||||
<objective>
|
||||
PHASE TRACER. Wire one main tab end to end through every layer this phase touches —
|
||||
route classification, the KeepAlive host inside `Dashboard.vue`'s nested RouterView, the
|
||||
`useCachedResource` reactivation gap, the tab's own data fetches, and the subtle refresh
|
||||
indicator — and prove with a runnable test that the tab renders from cache on revisit
|
||||
while revalidating in the background.
|
||||
|
||||
This is the phase's thin end-to-end slice, sequenced immediately after the D-10 profiling
|
||||
gate (plan 02-01), which CONTEXT.md locks as a hard prerequisite: no production source may
|
||||
change before the findings doc is committed. Every later plan in this phase expands
|
||||
horizontally from the architecture proven here. It is production quality, not a prototype
|
||||
— the only thing "thin" about it is that exactly one tab is converted.
|
||||
|
||||
**Tracer tab selection:** use the highest-ranked slow main tab from `02-FINDINGS.md`
|
||||
`## Ranked Fix Order`. Default and expected pick: `marketplace`
|
||||
(`neode-ui/src/views/Marketplace.vue`) — the app store the user reported as the worst
|
||||
surface, moderate size, and it exercises all three layers (instance cache, data cache,
|
||||
refresh indicator). If the ranking's top entry is `mesh`, take the next entry instead:
|
||||
`Mesh.vue` is 2,651 lines with a live D3 force graph and a Leaflet map, which exceeds a
|
||||
single task's context budget and is planned separately as 02-05. Record the pick and the
|
||||
reason in the SUMMARY.
|
||||
|
||||
Purpose: PERF-02 — main-tab switches render immediately from cached state with background
|
||||
refresh. Proving the whole path on one tab first means an architectural dead end costs one
|
||||
commit instead of ten.
|
||||
|
||||
Output: a working, instance-cached, stale-while-revalidate main tab; the shared
|
||||
KeepAlive host and route classifier every other tab will use; the hook fix that makes
|
||||
background refresh actually fire on revisit; and the tests that pin all of it.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-ui-performance/02-CONTEXT.md
|
||||
@.planning/phases/02-ui-performance/02-RESEARCH.md
|
||||
@.planning/phases/02-ui-performance/02-PATTERNS.md
|
||||
@.planning/phases/02-ui-performance/02-FINDINGS.md
|
||||
@.planning/codebase/CONVENTIONS.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: One main tab survives a tab round-trip and revalidates on return</name>
|
||||
<reversibility rating="costly">`Dashboard.vue`'s nested RouterView is the single mount point every dashboard view renders through, and `useCachedResource` already has eight consumers — undoing either shape later means touching every view again.</reversibility>
|
||||
<files>neode-ui/src/composables/useCachedResource.ts, neode-ui/src/composables/__tests__/useCachedResource.test.ts, neode-ui/src/views/dashboard/keepAliveRoutes.ts, neode-ui/src/views/dashboard/DashboardRouterView.vue, neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts, neode-ui/src/views/dashboard/useRouteTransitions.ts, neode-ui/src/views/Dashboard.vue</files>
|
||||
<read_first>
|
||||
- `neode-ui/src/views/Dashboard.vue` — read the template around lines 87-118. The nested `<RouterView v-slot="{ Component, route }">` sits inside `<Transition>`, whose child is a `<div :key="route.path" class="view-wrapper">` that then branches into two wrapper shapes. This is the structure being restructured; read it before touching it.
|
||||
- `neode-ui/src/composables/useCachedResource.ts` — all 107 lines. Note `refreshIfStale()` (line ~72), `stale()` (line ~71), the `getCurrentScope()` + `onScopeDispose` block (lines ~86-92), and the `if (opts.immediate ?? true) refreshIfStale()` call (line ~94) whose placement the new hook mirrors.
|
||||
- `neode-ui/src/stores/resources.ts` — the backing store; `refresh()` already dedupes concurrent calls per key via its `inflight` map, which is why an extra reactivation-triggered call on first mount is harmless.
|
||||
- `neode-ui/src/views/dashboard/useRouteTransitions.ts` — `TAB_ORDER` (lines 4-15) is the canonical main-tab path list; `getTransitionName()` must keep working unchanged; `isDetailRoute()` is deliberately NOT the classifier used here.
|
||||
- `neode-ui/src/router/index.ts` — the dashboard child routes and their `name` values; confirm the tracer tab's path and that detail routes such as `apps/:id` and `marketplace/:id` are siblings under the same parent.
|
||||
- `neode-ui/src/views/Marketplace.vue` — the tracer tab (unless the findings ranking says otherwise). Read `onMounted` at line ~377, `loadCommunityMarketplace()`, `loadBitcoinPruneStatus()` (fetches `/bitcoin-status`), and the `marketplaceAnimationDone` one-shot flag.
|
||||
- `neode-ui/src/views/Cloud.vue` lines 505-530 and 945-970 — the in-repo reference for defining a cached resource and for the keep-last-value error handling to mirror.
|
||||
- `neode-ui/src/views/__tests__/CloudPeersRefresh.test.ts` — the in-repo Vitest + `@vue/test-utils` + Pinia mounting pattern to follow for the new tests.
|
||||
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — the ranked fix order that selects the tracer tab, and the measured cause for that tab.
|
||||
</read_first>
|
||||
<action>
|
||||
Write the failing tests first, then make them pass.
|
||||
|
||||
**A. Route classifier.** Create `neode-ui/src/views/dashboard/keepAliveRoutes.ts`
|
||||
exporting `KEEP_ALIVE_MAX` (set to 6), `KEEP_ALIVE_PATHS` (a `ReadonlySet<string>`
|
||||
seeded with ONLY the tracer tab's path — plan 02-04 widens it after the lifecycle
|
||||
audit), and `shouldKeepAlive(route: RouteLocationNormalizedLoaded | { path: string })`
|
||||
returning true only for an exact path match. Exact-match, not prefix-match: a
|
||||
prefix match would sweep in `/dashboard/marketplace/:id` and every other secondary
|
||||
screen, which D-04 forbids from the instance cache. Do not derive this from
|
||||
`isDetailRoute()` — that helper only recognises `/apps/` and `/marketplace/` details
|
||||
and misses `cloud/:folderId`, `server/openwrt`, `web5/credentials`, `goals/:goalId`
|
||||
and `app-session/:appId`. Export `TAB_ORDER` from `useRouteTransitions.ts` (it is
|
||||
currently a module-private `const`) so plan 02-04 can widen `KEEP_ALIVE_PATHS` from
|
||||
it without editing that file again.
|
||||
|
||||
Deliberately do not use `<KeepAlive :include>` name matching. Every one of the 44
|
||||
routes is an async component (`component: () => import(...)`) and no view in this
|
||||
codebase calls `defineOptions({ name })`, so `include` would depend on name
|
||||
inference through the async wrapper — the failure mode RESEARCH.md flags as
|
||||
assumption A1 (vuejs/core issue 11764). Route-path classification sidesteps it and
|
||||
is also the fix for RESEARCH.md pitfall 7.
|
||||
|
||||
**B. Extract and restructure the KeepAlive host.** Create
|
||||
`neode-ui/src/views/dashboard/DashboardRouterView.vue` holding the nested
|
||||
`<RouterView v-slot="{ Component, route }">` currently inlined in `Dashboard.vue`,
|
||||
taking `mobileTabPaddingTop: number | null` and `needsMobileBackButtonSpace: boolean`
|
||||
as props (both are computed in `Dashboard.vue` today). Replace that inline block in
|
||||
`Dashboard.vue` with `<DashboardRouterView :mobile-tab-padding-top="..."
|
||||
:needs-mobile-back-button-space="..." />`.
|
||||
|
||||
Four structural invariants govern the new template, and the tests below exist to
|
||||
pin them:
|
||||
|
||||
1. Nothing between the RouterView slot and `<KeepAlive>` may carry a binding that
|
||||
changes identity per route. The current `<div :key="route.path">` sits exactly
|
||||
there; if a `<KeepAlive>` is nested under it, that div is torn down on every
|
||||
navigation and takes the entire instance cache with it, producing a change that
|
||||
reviews clean and improves nothing. Hoist the wrapper out and drive its
|
||||
appearance from route-derived computed values instead of from a changing key.
|
||||
2. Composition order is `<Transition>` outside `<KeepAlive>` outside
|
||||
`<component :is="Component">`.
|
||||
3. The `:key="route.path"` binding belongs on `<component :is>` itself, never on an
|
||||
ancestor of `<KeepAlive>`.
|
||||
4. Both existing wrapper shapes must survive byte-for-byte in their visual result:
|
||||
the chat/mesh branch (`h-full`, plus `dashboard-scroll-panel mobile-scroll-pad
|
||||
mesh-dashboard-panel` for the mesh path, plus `overflow-y-auto` and the
|
||||
`mobileTabPaddingTop + 16` padding when that prop is set, plus `mobile-safe-top`)
|
||||
and the default branch (`absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto
|
||||
mobile-safe-top dashboard-scroll-panel`, plus `mobile-scroll-pad-back` or
|
||||
`mobile-scroll-pad`, the `view-container flex-none` class applied to the rendered
|
||||
component, and the trailing `shrink-0 h-6 md:h-12` spacer div).
|
||||
|
||||
Express the two shapes as computed helpers in the new component (for example
|
||||
`isFullBleedRoute(route)`, `wrapperClass(route)`, `wrapperStyle(route)`) applied to a
|
||||
single stable wrapper element, and render two sibling branches inside it: a
|
||||
`<Transition><KeepAlive :max="KEEP_ALIVE_MAX"><component :is="Component"
|
||||
:key="route.path" v-if="shouldKeepAlive(route)" /></KeepAlive></Transition>` branch
|
||||
and a plain `<Transition><component :is="Component" :key="route.path"
|
||||
v-else /></Transition>` branch for everything else. `getTransitionName(route)` keeps
|
||||
driving both.
|
||||
|
||||
Because the default branch's wrapper is the scroll container and it is now stable
|
||||
across routes, add explicit per-route scroll retention in the new component: keep a
|
||||
`Map<string, number>` of `scrollTop` by route path, write the outgoing path's value
|
||||
in a `watch` on `route.path` before the new view paints, and restore the incoming
|
||||
path's value on `nextTick` after it does. Without this, a kept-alive tab would inherit
|
||||
the previous tab's scroll offset, which is worse than today's reset-to-top.
|
||||
|
||||
**C. Close the reactivation gap in the hook.** In
|
||||
`neode-ui/src/composables/useCachedResource.ts`, import `onActivated` from `vue` and
|
||||
register `onActivated(() => refreshIfStale())` inside the existing
|
||||
`if (getCurrentScope())` block, alongside `onScopeDispose`. Vue no-ops this hook
|
||||
outside a `<KeepAlive>` boundary, so it is safe for all eight existing consumers.
|
||||
Without it a kept-alive tab paints instantly forever and never revalidates, because
|
||||
`onScopeDispose` does not fire on deactivate and the `window` focus listener does not
|
||||
fire on an in-SPA tab switch. Add a short comment above it naming why reactivation is
|
||||
a distinct trigger from mount and from focus.
|
||||
|
||||
**D. Register the tracer tab.** Seed `KEEP_ALIVE_PATHS` with exactly the tracer tab's
|
||||
path and nothing else. Its data conversion lands in Task 2; this task proves the
|
||||
instance survives and that the hook revalidates on reactivation, which is the
|
||||
architectural question. Plan 02-04 widens the set after auditing every tab's
|
||||
lifecycle — do not widen it here.
|
||||
|
||||
**E. The tests.** Create
|
||||
`neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` mounting
|
||||
`DashboardRouterView` with a `createRouter` on `createMemoryHistory` and two stub
|
||||
route components that each increment a module-level mount counter in `onMounted` and
|
||||
an activation counter in `onActivated`. Assert: navigating to the kept-alive path,
|
||||
away, and back leaves the mount counter at 1 and the activation counter at 2; the
|
||||
same round-trip on a detail path such as `/dashboard/marketplace/abc` leaves that
|
||||
stub's mount counter at 2; and `shouldKeepAlive` returns false for a detail path
|
||||
whose prefix matches an included path.
|
||||
|
||||
Create `neode-ui/src/composables/__tests__/useCachedResource.test.ts` mounting a
|
||||
consumer component inside a real `<KeepAlive>` with a `vi.fn()` fetcher. Assert:
|
||||
deactivate and reactivate inside the TTL calls the fetcher no additional times;
|
||||
deactivate, advance fake timers past the TTL, reactivate calls it exactly once more;
|
||||
the same hook used outside any `<KeepAlive>` mounts and fetches without throwing; a
|
||||
rejected refresh leaves `entry.data` at its previous value with `entry.error` set;
|
||||
and `loadState` moves ready to refreshing rather than back to loading.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts src/composables/__tests__/useCachedResource.test.ts && npm run type-check</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts` exits 0
|
||||
- `npm run test -- src/composables/__tests__/useCachedResource.test.ts` exits 0
|
||||
- `npm run type-check` exits 0
|
||||
- `npm run test` (full suite) exits 0 — the eight existing `useCachedResource` consumers are unregressed
|
||||
- In the round-trip test the kept-alive stub records exactly 1 mount and 2 activations; the detail-route stub records 2 mounts
|
||||
- `neode-ui/src/views/dashboard/keepAliveRoutes.ts` exports `shouldKeepAlive`, `KEEP_ALIVE_PATHS` and `KEEP_ALIVE_MAX`, and `shouldKeepAlive({ path: '/dashboard/marketplace/abc' })` returns false
|
||||
- `neode-ui/src/composables/useCachedResource.ts` imports `onActivated` from `vue`: `grep -c "onActivated" neode-ui/src/composables/useCachedResource.ts` is at least 2
|
||||
- `neode-ui/src/views/dashboard/DashboardRouterView.vue` contains `KeepAlive` and `shouldKeepAlive`, and `neode-ui/src/views/Dashboard.vue` renders `DashboardRouterView`
|
||||
- `KEEP_ALIVE_PATHS` contains exactly one entry — the tracer tab's path
|
||||
- `npm run build` exits 0 and the built bundle carries the new code: after building, `grep -rl "shouldKeepAlive\|KeepAlive" web/dist/neode-ui/assets | head -1` prints a file (CLAUDE.md warns the frontend build can silently no-op)
|
||||
</acceptance_criteria>
|
||||
<done>The tracer tab renders from a surviving component instance on revisit, the hook revalidates on reactivation only when stale, a detail route still mounts fresh, and the full Vitest suite is green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Subtle refresh indicator and correct per-visit behavior on the tracer tab</name>
|
||||
<files>neode-ui/src/components/RefreshIndicator.vue, neode-ui/src/views/Marketplace.vue, neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts</files>
|
||||
<read_first>
|
||||
- `neode-ui/src/views/Marketplace.vue` — the tracer tab as left by Task 1; read its header/toolbar markup to find where a refresh affordance belongs, and its `marketplaceAnimationDone` one-shot flag at `onMounted` (line ~377)
|
||||
- `neode-ui/src/components/` — list it and read two or three existing small components to match the house glass/dark styling, spacing and `<script setup lang="ts">` prop-typing conventions
|
||||
- `neode-ui/src/stores/resources.ts` — the `ResourceLoadState` union (`idle | loading | ready | refreshing | error`) that the indicator's prop is typed against
|
||||
- `.planning/codebase/CONVENTIONS.md` — component file structure, `defineProps<{}>()` typing, and the "types not enums" rule
|
||||
- `.planning/phases/02-ui-performance/02-CONTEXT.md` — D-05 (subtle indicator, no stale-age badges), D-07 (silent keep-last-value), D-08 (persist policy)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- RefreshIndicator renders nothing when `state` is `ready` or `idle`
|
||||
- RefreshIndicator renders its indicator element when `state` is `refreshing`
|
||||
- RefreshIndicator renders nothing when `state` is `loading` — a first load is the view's own skeleton's job, not this component's
|
||||
- The indicator element carries an accessible label and `aria-live="polite"` so a background refresh is announced without stealing focus
|
||||
- When a background refresh on the tracer tab rejects, the previously rendered content is still in the DOM and no toast function is called
|
||||
</behavior>
|
||||
<action>
|
||||
First put the tracer tab's data on the cache, following the `Cloud.vue` pattern: a
|
||||
keyed resource per logical dataset, `computed` views over `entry.data` and
|
||||
`entry.loadState`, and keep-last-value error handling that sets a banner ref rather
|
||||
than raising a toast (D-07). For `Marketplace.vue` that means the shared app-catalog
|
||||
fetch behind `loadCommunityMarketplace()` (key `app-catalog`, a long TTL of 300000 ms
|
||||
— the catalog is near-static, per the D-06 discretion) and the `/bitcoin-status` fetch
|
||||
behind `loadBitcoinPruneStatus()` (key `bitcoin.prune-status`, the 30000 ms default).
|
||||
Put the catalog fetch behind a shared key rather than a Marketplace-private one so
|
||||
`Discover.vue`, which calls the same loader, picks up the same cache entry without its
|
||||
own conversion in plan 02-04. Decide `persist` explicitly per resource rather than
|
||||
taking the default: `app-catalog` and `bitcoin.prune-status` are non-sensitive and
|
||||
small, so both persist. Pass `dedup: true` on the underlying calls.
|
||||
|
||||
Then create `neode-ui/src/components/RefreshIndicator.vue`: a small presentational
|
||||
component taking `state: ResourceLoadState` and an optional `label?: string`. It
|
||||
renders a compact spinner or shimmer sized to sit inline in a view header — small
|
||||
enough to read as ambient rather than as a blocking loader. Match the existing
|
||||
design system: reuse the spinner treatment already present in the codebase (for
|
||||
example the `chat-loading-spinner` rule in `Chat.vue`'s scoped styles) rather than
|
||||
inventing a second spinner idiom, and use the same glass/white-alpha palette as its
|
||||
neighbours. Exact placement and styling are Claude's discretion per CONTEXT.md, but
|
||||
it must not shift layout when it appears and disappears — reserve its space or
|
||||
position it absolutely.
|
||||
|
||||
Wire it into the tracer tab's header, bound to the tab's primary resource
|
||||
`loadState`. Do not surface stale-age text or a "last updated" badge — D-05 rules
|
||||
those out.
|
||||
|
||||
Then correct the tracer tab's per-visit behavior now that its instance survives.
|
||||
`onMounted` fires exactly once for the lifetime of a kept-alive instance, so audit
|
||||
every side effect in that view and place each one deliberately:
|
||||
- Genuinely once-per-session setup stays in `onMounted`.
|
||||
- Anything that should re-run on every tab entry moves to `onActivated`.
|
||||
- Anything that should stop while the tab is off screen (intervals, subscriptions,
|
||||
window listeners) gains a matching `onDeactivated` teardown, with `onActivated`
|
||||
re-arming it.
|
||||
For `Marketplace.vue` specifically: `marketplaceAnimationDone` is a one-shot intro
|
||||
flag and stays where it is; the catalog and prune-status loads are now cache-gated
|
||||
and revalidate through the hook's own `onActivated`, so they need no per-view hook.
|
||||
Record in the SUMMARY every side effect you moved and every one you deliberately
|
||||
left in `onMounted`, with the reason — plan 02-04 repeats this audit across the
|
||||
remaining tabs and needs the precedent.
|
||||
|
||||
Confirm the error path matches D-07: a failed background refresh keeps the last
|
||||
known content on screen and sets the view's existing error banner ref; it must not
|
||||
call the toast composable. Errors surface on an explicit user-triggered refresh only.
|
||||
|
||||
Extend the existing test file with an indicator test: mount `RefreshIndicator` for
|
||||
each `ResourceLoadState` value and assert the render-nothing / render-something
|
||||
matrix in the behavior block above.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts && npm run type-check && npm run test</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `neode-ui/src/components/RefreshIndicator.vue` exists and its props are typed with `defineProps<{ state: ResourceLoadState; label?: string }>()`
|
||||
- Mounting `RefreshIndicator` with `state: 'refreshing'` renders a non-empty element; with `state: 'ready'`, `'idle'` and `'loading'` it renders nothing
|
||||
- The rendered indicator element carries `aria-live="polite"` and a non-empty accessible label
|
||||
- The tracer tab view imports `useCachedResource`, defines the `app-catalog` and `bitcoin.prune-status` keys with explicit `ttlMs` and `persist` values, and no longer calls those loaders from `onMounted` without a cached resource behind them
|
||||
- The tracer tab view imports and renders `RefreshIndicator` bound to a resource `loadState`
|
||||
- `npm run test` exits 0 and `npm run type-check` exits 0
|
||||
- A rejected background refresh in the tracer tab test leaves the prior data rendered and invokes no toast
|
||||
- The SUMMARY lists each side effect that moved to `onActivated`/`onDeactivated` and each one deliberately left in `onMounted`, with reasons
|
||||
</acceptance_criteria>
|
||||
<done>The tracer tab shows a subtle, non-layout-shifting refresh indicator during background revalidation, keeps its content on a failed refresh without a toast, and every one of its side effects is deliberately placed for the kept-alive lifecycle.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: Confirm the tracer tab feels instant on the dev preview against archi-dev</name>
|
||||
<what-built>
|
||||
The KeepAlive host inside `Dashboard.vue`'s nested RouterView (extracted to
|
||||
`DashboardRouterView.vue`), a route-path classifier capping the instance cache at 6,
|
||||
the `onActivated` revalidation fix in `useCachedResource`, the tracer tab's data
|
||||
moved onto keyed cached resources, and a subtle refresh indicator. Automated proof
|
||||
already passing: component instance survives a tab round-trip, detail routes still
|
||||
remount, no refetch inside the TTL, exactly one background refetch after it.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
1. From the repo root run `./scripts/dev-start.sh` and open the :8100 dev preview
|
||||
pointed at archi-dev, per the phase's dev discipline (password `password123`).
|
||||
2. Open the tracer tab (the app store / Marketplace unless the SUMMARY says
|
||||
otherwise). Let it finish loading.
|
||||
3. Switch to another main tab, then switch back. Expected: content appears
|
||||
immediately — no spinner, no blank frame, no intro animation replay. Search text,
|
||||
selected category and scroll position are as you left them.
|
||||
4. Stay on another tab for longer than the TTL, then return. Expected: content is
|
||||
still there instantly, and the small refresh indicator appears briefly in the
|
||||
header while the data revalidates behind it. No full-screen loader, no layout jump.
|
||||
5. Open a secondary screen from that tab (tap an app to reach its detail page), go
|
||||
back, and open a different app. Expected: the detail screen behaves as before —
|
||||
this plan deliberately does not instance-cache secondary screens.
|
||||
6. Confirm the tab transition animation still plays when moving between main tabs,
|
||||
and that it is the same animation as before this change.
|
||||
7. Stop the backend (or pull the node's network) and return to the tracer tab after
|
||||
the TTL. Expected: the previous content stays on screen, no error toast appears.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved", or describe what you saw: which step, what happened instead.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| node RPC / HTTP responses → browser cache | Untrusted-until-validated response payloads now live longer, in memory and in sessionStorage |
|
||||
| browser tab session → sessionStorage | Cached payloads survive in-tab navigation and reload, readable by any script running on the origin |
|
||||
| authenticated session → cached view instances | A kept-alive component instance holds rendered data across navigations and across a logout |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-02-01 | Information Disclosure | `useCachedResource` default `persist: true` writing to sessionStorage | high | mitigate | Task 1D requires an explicit per-resource `persist` decision rather than the default. The two tracer-tab resources (`app-catalog`, `bitcoin.prune-status`) are non-sensitive and persist; any resource carrying wallet figures, transaction history, credentials, DIDs or peer identity is memory-only (`persist: false`) |
|
||||
| T-02-02 | Information Disclosure | Cached entries surviving a logout or identity switch | high | mitigate | Cache keys used here are node-global and non-identity-bearing. Purging the `resources` store and its `resource:` sessionStorage prefix on logout is specified and verified in plan 02-03 Task 3, which owns the identity-scoping work; this plan must not introduce an identity-bearing key before that lands |
|
||||
| T-02-03 | Denial of Service | `<KeepAlive>` instance cache on low-power fleet nodes | medium | mitigate | `KEEP_ALIVE_MAX` of 6 caps resident instances with LRU eviction (D-03); Task 2 requires intervals and subscriptions to stop on `onDeactivated` so an off-screen tab costs no CPU; on-device memory is verified in plan 02-08 |
|
||||
| T-02-09 | Tampering | Restructured `Dashboard.vue` render path | low | accept | The change is render-composition only — no auth guard, route guard or data-validation path is touched. `router/index.ts`'s existing navigation guards are unmodified |
|
||||
| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope. `<KeepAlive>` is Vue core, `onActivated` is Vue core, and every other primitive already ships in this repo. If a task finds it needs a new dependency it stops and routes through the Package Legitimacy Gate with a blocking human checkpoint before installing |
|
||||
</threat_model>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this phase produces
|
||||
|
||||
Symbols and paths created by this plan — new API, not drift from the existing codebase:
|
||||
|
||||
- `neode-ui/src/views/dashboard/keepAliveRoutes.ts` — `shouldKeepAlive()`, `KEEP_ALIVE_PATHS`, `KEEP_ALIVE_MAX`
|
||||
- `neode-ui/src/views/dashboard/DashboardRouterView.vue` — props `mobileTabPaddingTop`, `needsMobileBackButtonSpace`; internal helpers `isFullBleedRoute()`, `wrapperClass()`, `wrapperStyle()`
|
||||
- `neode-ui/src/components/RefreshIndicator.vue` — props `state: ResourceLoadState`, `label?: string`
|
||||
- `neode-ui/src/composables/__tests__/useCachedResource.test.ts`
|
||||
- `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts`
|
||||
- `TAB_ORDER` — promoted from module-private to an export of `neode-ui/src/views/dashboard/useRouteTransitions.ts`
|
||||
- Cache keys introduced: `app-catalog`, `bitcoin.prune-status`
|
||||
|
||||
Created elsewhere in Phase 02: `neode-ui/e2e/perf/{surfaces,measure,surface-perf.spec}.ts`,
|
||||
`.planning/phases/02-ui-performance/{02-FINDINGS.md,02-PERF-BASELINE.json,02-PERF-AFTER.json}`.
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<assumptions_and_flagged_items>
|
||||
## Assumptions & Flagged Items
|
||||
|
||||
- **PERF-02 edge-probe row (spec-less fallback):** returned `unclassified` / `unresolved`. FLAGGED, not auto-backstopped and not dropped. Resolved in substance by this plan's `must_haves.truths`; one truth (transition-animation parity) is carried as a `verification: backstop` marker because it is a perceptual property the unit tests cannot confirm. The probe row itself stays unresolved and is surfaced here for human review.
|
||||
- **FA-A (correction to `02-PATTERNS.md`):** PATTERNS.md line 43 advises leaving `:key="route.path"` on the outer wrapper `<div>`. That is unsafe once a `<KeepAlive>` is nested beneath it — a keyed ancestor is torn down on every navigation and destroys the instance cache, producing a change that reads correct and improves nothing. Task 1's round-trip mount-count assertion is the resolution.
|
||||
- **FA-C (RESEARCH assumption A1):** whether Vue 3.5.24 fixes `KeepAlive` `include`/`exclude` name matching for async components is not settled. Resolved by design — this plan never uses `include`/`exclude`; classification is by route path.
|
||||
- **FA-D (RESEARCH assumption A2):** the `max` cap value. Set to 6 against 10 entries in `TAB_ORDER`, so the long tail evicts while a normal working set stays resident. Not validated on hardware yet; plan 02-08 tunes it against on-device memory.
|
||||
- **FA-F (correction to `02-CONTEXT.md` canonical_refs):** `App.vue`'s RouterView is not the remount point — it only ever swaps `OnboardingWrapper`, `Dashboard` and `NotFound`. The real point is the nested RouterView in `Dashboard.vue`, extracted here to `DashboardRouterView.vue`.
|
||||
- **Open:** the scroll-retention `Map` is unbounded in principle (one number per visited path). Path count is bounded by the route table, so this is accepted rather than mitigated.
|
||||
</assumptions_and_flagged_items>
|
||||
|
||||
<verification>
|
||||
- `cd neode-ui && npm run test` exits 0
|
||||
- `cd neode-ui && npm run type-check` exits 0
|
||||
- `cd neode-ui && npm run build` exits 0 and the new symbols appear in `web/dist/neode-ui/assets`
|
||||
- The human-verify checkpoint is approved against archi-dev on the :8100 preview
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- One main tab renders instantly from a surviving component instance on revisit, with no spinner and no blank frame
|
||||
- A stale return fires exactly one background revalidation, visible as a subtle indicator, with content never leaving the screen
|
||||
- A failed background refresh is silent and non-destructive
|
||||
- Secondary screens are unaffected — they still mount fresh
|
||||
- The instance cache is capped, and the shared classifier, host component and hook fix are in place for every later plan to build on
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-ui-performance/02-02-SUMMARY.md` when done. It MUST record:
|
||||
the tracer tab actually chosen and why; the final `DashboardRouterView.vue` template
|
||||
shape; every side effect moved to `onActivated`/`onDeactivated` versus left in
|
||||
`onMounted`, with reasons; and the `persist` decision made for each new cache key.
|
||||
Plans 02-04, 02-05, 02-06 and 02-07 read all four from this file.
|
||||
</output>
|
||||
Reference in New Issue
Block a user