Files
archy/.planning/phases/01-federation-mesh-hardening/01-14-SUMMARY.md
T

189 lines
16 KiB
Markdown

---
phase: 01-federation-mesh-hardening
plan: 14
subsystem: ui
tags: [vue, composable, lightbox, loading-state, vitest]
requires:
- phase: 02-ui-performance
provides: "PERF-02 no-spinner-on-cached-revisit guarantee, useCachedResource/KeepAlive infrastructure, and the split-signal-cost root-cause findings for the eight named slow-open surfaces"
provides:
- "usePaidItemViewer() composable — fetch/decode/route/loading/error state for a purchased item, replacing the inline window.open() path"
- "A second MediaLightbox instance in Cloud.vue, fed synthetic FileBrowserItems for purchased images/video"
- "Row-level 'Opening…' loading affordance and non-interactive-while-loading guard on Paid Files rows"
- "Evidence-backed per-surface verdict for every surface phase 2's findings named as a slow-open regression"
affects: [cloud, media-viewer]
tech-stack:
added: []
patterns:
- "Fetch-before-show: the composable fetches+decodes the purchased blob BEFORE opening the lightbox, so the lightbox's own fetchBlobUrl/streamUrl props resolve instantly from an internal map — the row's own 'opening' spinner covers the real (possibly-slow) network wait, not the lightbox's own (now-instant) internal loading flash."
- "One key, one owner: opening/inFlight/urlByPath are all keyed by the same paidItemKey(onion+content_id) formula the row's v-for :key already uses, so the row's spinner condition and the composable's dedup guard can never drift apart."
key-files:
created:
- neode-ui/src/composables/usePaidItemViewer.ts
- neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts
modified:
- neode-ui/src/views/Cloud.vue
key-decisions:
- "Documents (and any other mime with no in-app viewer) keep the browser-tab fallback unchanged — this is a scope boundary from the plan, not an omission."
- "All eight surfaces phase 2 flagged as slow-opening (Discover, Server, Web5, Fleet, AppDetails, OpenWrtGateway, MarketplaceAppDetails, Wallet-send) are CACHED-REVISIT render-cost regressions, not missing-first-load-affordance gaps — every one already shows a loading affordance on its genuinely slow path (first load or the specific in-flight async op), so no loader was added anywhere in Task 2. Adding one to any of them would put a spinner on a cached revisit, a direct PERF-02 regression."
- "URL ownership: the lightbox revokes every URL it is handed, on unmount, exactly as it already does for My Files/Peer Files. The composable never schedules a competing revoke for a URL routed to the lightbox — only the browser-tab fallback path keeps its own revoke timer, unchanged from before."
patterns-established:
- "Row-level loading + non-interactive guard for a fetch-then-route action, reusing PeerFiles.vue's existing 3x3 spinner + 'Opening...' treatment rather than inventing new visual language."
requirements-completed: [UIFIX-04, UIFIX-06]
coverage:
- id: D1
description: "A purchased picture or video opens in the app's own MediaLightbox instead of a browser tab"
requirement: UIFIX-04
verification:
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#routes an image mime to the lightbox, not window.open"
status: pass
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#routes a video mime to the lightbox, not window.open"
status: pass
human_judgment: false
- id: D2
description: "Audio still routes to the global bottom-bar player, never the lightbox; documents keep today's browser-tab fallback"
requirement: UIFIX-04
verification:
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#routes an audio mime to the audio player, never the lightbox"
status: pass
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#falls back to the browser tab for a mime with no in-app viewer"
status: pass
human_judgment: false
- id: D3
description: "The Paid Files row shows a visible loading affordance for the whole duration of a purchased-item fetch, and clears it (including on failure); a second click on the same item in flight issues one RPC"
requirement: UIFIX-06
verification:
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#sets opening for the whole duration of the fetch and clears it on success"
status: pass
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#surfaces a rejected/timed-out fetch as an error, clears opening, and does not throw past the caller"
status: pass
- kind: unit
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#issues exactly one RPC when open() is called twice in quick succession for the same item"
status: pass
human_judgment: false
- id: D4
description: "Every surface phase 2's findings named as slow-opening has a recorded, evidence-backed verdict; no loader was added to a cached-revisit path"
requirement: UIFIX-06
verification:
- kind: other
ref: "Per-surface verdict table below, cross-referenced against .planning/phases/02-ui-performance/02-FINDINGS.md's Per-surface verdict / Accepted deviations sections and direct grep of each surface's own loading-state code"
status: pass
human_judgment: false
duration: 30min
completed: 2026-07-31
status: complete
---
# Phase 01 Plan 14: Paid Files lightbox + loading-state audit Summary
**Purchased pictures/videos now open in the app's own MediaLightbox with a visible "Opening…" row spinner and real error surfacing, replacing `window.open()` + a swallowed catch; the eight other surfaces phase 2 flagged as slow are confirmed cached-revisit render-cost regressions, not missing loaders, so none of them were touched.**
## Performance
- **Duration:** ~30 min
- **Started:** 2026-07-31T22:00Z (approx, from first read)
- **Completed:** 2026-07-31T22:30Z
- **Tasks:** 2 (Task 1 tracer + Task 2 audit)
- **Files modified:** 3
## Accomplishments
- New `usePaidItemViewer()` composable: moves `Cloud.vue`'s inline `content.owned-get` fetch/decode chain into a reusable, unit-tested unit with per-mime routing (image/video → lightbox, audio → bottom-bar player, everything else → today's browser-tab fallback), a single-flight `open()` guard keyed on `onion+content_id`, and a `resolveBlobUrl` the lightbox calls to read the already-fetched URL.
- `Cloud.vue`'s Paid Files row: `window.open()` is gone entirely (`grep -c 'window.open'` = 0); the row now shows a spinner + "Opening…" label (reusing `PeerFiles.vue`'s existing treatment verbatim) for the fetch's duration and becomes non-interactive so a second click can't queue a second fetch.
- A second `MediaLightbox` instance added below the existing My Files one, fed the composable's `lightboxItems`/`lightboxIndex` and `resolveBlobUrl` for both `fetch-blob-url` and `stream-url`.
- The composable's `catch` sets a real, user-facing `error` message that Cloud.vue surfaces through its existing `loadError`/`alert-error` block — no more silent failure.
- Task 2 audit: every one of the eight surfaces phase 2's findings named (Discover, Server, Web5, Fleet, AppDetails, OpenWrtGateway, MarketplaceAppDetails, Wallet-send) confirmed as a cached-revisit client-side render-cost regression with an existing first-load/first-fetch affordance already in place — no new loader added anywhere.
## Task Commits
1. **Task 1: End-to-end — a purchased picture opens in the lightbox, with the wait visible** - `bc9a210c` (fix)
- Follow-up type-check fix (vue-tsc caught two possibly-undefined array reads `npx vitest run` alone doesn't flag): `4a8925f0` (fix)
2. **Task 2: Settle the slow-open inventory — verdict per surface, loader only where genuinely missing** - no code changes; audit-only, documented below (the pinning test required by this task's acceptance criteria was already written as part of Task 1's test file, see "opening is not derived from a background-refresh flag" test)
**Plan metadata:** (this commit)
## Files Created/Modified
- `neode-ui/src/composables/usePaidItemViewer.ts` - fetch/decode/route/loading/error composable for purchased items
- `neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts` - 9 tests: per-mime routing (image/video/audio/no-viewer), extension-carrying synthetic item, loading-state, the not-derived-from-refresh pin, error surfacing, double-click dedup
- `neode-ui/src/views/Cloud.vue` - `viewPaidItem()` now delegates to the composable; row loading/error UI; second `MediaLightbox` instance
## Per-Surface Slow-Open Verdict (Task 2)
Audited against `.planning/phases/02-ui-performance/02-FINDINGS.md`'s "Per-surface verdict" and "Accepted deviations" sections (the authoritative measured evidence) plus a direct grep of each surface's own loading-state code, per the plan's own instruction to re-verify rather than assume a grep hit covers the first uncached open.
| Surface | File | Existing affordance (first/genuinely-slow open) | Genuinely slow first open? | Action taken |
|---|---|---|---|---|
| Discover | `neode-ui/src/views/Discover.vue` | `catalogResource.entry.loadState === 'loading'` drives a loading message (`loadingCommunity`, line 303) | No — 02-FINDINGS: 0 RPC confound-free reading, regression is Apps-tab-KeepAlive-transit render/reactivation cost on **revisit**, not the first uncached open | None — first-load already covered; a revisit regression is out of scope for a loader (would violate PERF-02) |
| Server | `neode-ui/src/views/Server.vue` | Multiple `animate-spin` loading indicators per-section (e.g. line 91, 250) plus keyed `useCachedResource` per load group (02-06) | No — 02-FINDINGS/02-09: component instance confirmed to genuinely survive the round-trip (`vm.$.uid` proof); regression is 7 cached resources' reactivation cost on **revisit**, RPC count *improved* (8→2) | None — same reasoning; revisit-only cost |
| Web5 | `neode-ui/src/views/web5/Web5.vue` | `loadingDidDoc` text indicator (line 44) plus each sub-card's own `useCachedResource` | No — 02-FINDINGS: strongest zero-overlap evidence of pure client-side reactivation cost on a confirmed-surviving instance, 0 RPC throughout, all three measurement runs | None — same reasoning; revisit-only cost |
| Fleet | `neode-ui/src/views/Fleet.vue` | `animate-spin` loading state (lines 64, 93) | No — 02-FINDINGS: instance survives (confirmed), 0 RPC either time, the most severe of the split-signal regressions (330ms→2631ms) but purely a revisit/reactivation cost | None — same reasoning; revisit-only cost |
| AppDetails | `neode-ui/src/views/AppDetails.vue` | `credentialsLoading` computed from `credentialsResource.loadState.value === 'loading'`, passed to its credentials section (line 44/206) | No — 02-FINDINGS: RPC count *improved* (2→1) via keyed `useCachedResource` (02-03); the keyed-lookup/re-render cost now exceeds what the eliminated fetch saved, on revisit | None — same reasoning; revisit-only cost |
| OpenWrtGateway | `neode-ui/src/views/server/OpenWrtGateway.vue` | Explicit "Loading skeleton" block (line 522) with a documented first-load-only condition (line 102-104 comment) | No — 02-FINDINGS: same split-signal class as AppDetails; RPC evidence confounded by Server-tab transit but `revisitMs` alone shows the regression is on the cached path, and the skeleton is already gated to first-load-only | None — same reasoning; revisit-only cost, and the surface deliberately avoids showing a skeleton on revisit already (matches PERF-02) |
| MarketplaceAppDetails | `neode-ui/src/views/MarketplaceAppDetails.vue` | `v-if="loading"` full-card skeleton with `animate-spin` (line 7-8) | No — 02-FINDINGS: RPC evidence confounded by Home-tab transit; only `package.versions` is a trustworthy call and it improved; regression (if any residual) reads as revisit render cost, and the raw `remounted: false` reading is flagged as likely the same selector-ambiguity artifact 02-09 proved, not new instance caching | None — first-load skeleton already present and correctly gated |
| Wallet / send flow (`SendBitcoinModal.vue`) | `neode-ui/src/components/SendBitcoinModal.vue` | `feeEstimateLoading` ref renders `'…'` in place of the fee figure while the fee-estimate call is in flight (lines 388-419) | No — 02-FINDINGS: **cleared as environmental noise** in the re-measurement (median dropped below both prior runs); the separate, still-open anomaly (revisit consistently slower than first-visit, 0 RPC) is because `BaseModal`'s `v-if` always fully remounts the modal — not a missing loader, and out of this plan's `files_modified` (`SendBitcoinModal.vue` isn't in this plan's scope; carried forward in 02-FINDINGS' Outstanding section) | None — the modal's own async op (fee estimate) already has a first-load affordance; the remount-cost anomaly is a pre-existing, separately-tracked issue this plan doesn't own |
**No affordance was added to any cached-revisit path in this plan.** PERF-02's no-spinner-on-revisit guarantee is preserved everywhere — the only genuinely slow, uncached, first-open gap that existed (`Cloud.vue`'s `viewPaidItem()` / `content.owned-get`, 60s timeout, zero indicator) is the one fixed in Task 1; every other named surface's slow-open regression is client-side render/reactivation cost on an already-cached revisit, which a loader cannot fix and must not paper over.
## Decisions Made
- Documents (no in-app viewer) deliberately keep the browser-tab fallback — a scope boundary named explicitly in the plan, not a gap.
- No new loader was added anywhere in Task 2 — see the per-surface verdict table above; every named surface's regression is revisit-only render cost, confirmed by phase 2's own measured evidence (`02-FINDINGS.md`), not a missing first-load affordance.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Fixed two TS strict-null-check errors caught by `npm run build`'s `vue-tsc -b` pass**
- **Found during:** Task 1, post-commit `npm run build` verification
- **Issue:** `viewer.lightboxItems.value[0].name` — TS2532 "Object is possibly 'undefined'" on two array-index test assertions. `npx vitest run` alone doesn't type-check test files under this project's config, so this only surfaced during the build's own `vue-tsc -b` step.
- **Fix:** Optional-chained both reads (`viewer.lightboxItems.value[0]?.name`).
- **Files modified:** `neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts`
- **Verification:** `npm run build` exits 0 after the fix; `npx vitest run` for the file still 9/9 green.
- **Committed in:** `4a8925f0`
---
**Total deviations:** 1 auto-fixed (blocking, build type-check)
**Impact on plan:** Test-only, no behavior change. No scope creep.
## Issues Encountered
None beyond the above.
## Verification
- `cd neode-ui && npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts` — 9/9 passed.
- `cd neode-ui && npx vitest run` — 99 test files / 812 tests, all green (`keepAliveTabs.test.ts` confirmed byte-for-byte unmodified via `git status --short` and still passing within that run).
- `cd neode-ui && npx vue-tsc --noEmit` — clean.
- `cd neode-ui && npm run build` — succeeds; `grep -rq 'Opening…' ../web/dist/neode-ui/assets/Cloud-*.js` confirms the new string reached the bundle.
- `grep -c 'window.open' neode-ui/src/views/Cloud.vue` = 0; `grep -c 'MediaLightbox' neode-ui/src/views/Cloud.vue` = 4 (import + 3 usages incl. the doc comment referencing it); `grep -c 'usePaidItemViewer' neode-ui/src/views/Cloud.vue` = 3.
- `git diff -- neode-ui/src/views/Cloud.vue | grep -c "^-.*key: 'cloud\."` = 0 — no cached-resource key touched.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- UIFIX-04 and UIFIX-06 are both closed for their named gap (Paid Files). No blockers for future phase-1 plans.
- Carried-forward, unrelated-to-this-plan items already tracked in `02-FINDINGS.md`'s Outstanding section (Discover/Server/Web5/Fleet/AppDetails/OpenWrtGateway split-signal render-cost regressions, Wallet-send's remount anomaly) remain open there — this plan's audit reconfirmed them but did not fix them, per the plan's own explicit instruction that a cached-revisit render-cost regression is not a UIFIX-06 loader gap.
---
*Phase: 01-federation-mesh-hardening*
*Completed: 2026-07-31*