feat(02-03): app detail screens paint from cache on repeat visits
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m26s

- AppDetails.vue: bitcoin-sync and credentials converted to keyed
  useCachedResource (app-details:bitcoin-sync:<id>, app-details:credentials:<id>),
  each keyed by the route app id so two items never collide. Credentials
  is persist:false (credential material, D-08/T-02-01). Both loaders stay
  fire-and-forget from onMounted (already parallel — not touched). Stop/
  restart/uninstall now invalidate() the credentials resource so a stale
  healthy state can't outlive a destructive action (T-02-12).
- MarketplaceAppDetails.vue: the one RPC call in this view that isn't a
  measurement artifact of the Home-tab-transit confound (package.versions)
  is now a keyed useCachedResource (app-details:versions:<id>, 120s TTL —
  near-static catalog metadata). getCurrentApp() is a sync store read and
  the bitcoin-prune check is a plain fetch(), neither need conversion.
- secondaryScreenCache.test.ts: covers per-item isolation (rendered
  content, not just call counts), TTL-gated no-refetch, TTL-lapse
  revalidation, and keep-last-value on a rejected refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 08:13:35 -04:00
parent f44b8ac78c
commit 7c6c487a03
3 changed files with 426 additions and 38 deletions

View File

@ -83,6 +83,7 @@ import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
import { dummyApps } from '../utils/dummyApps'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import type { AppCredentialsResponse } from '@/types/api'
import BackButton from '@/components/BackButton.vue'
import AppHeroSection from './appDetails/AppHeroSection.vue'
@ -162,45 +163,69 @@ const gatewayState = computed(() => {
})
const needsBitcoinSync = computed(() => BITCOIN_DEPENDENT_APPS.includes(packageKey.value))
const bitcoinSyncPercent = ref(0)
const bitcoinBlockHeight = ref(0)
// Keyed per app id (D-04): AppDetails is never instance-cached (no
// KeepAlive), but the route's `:key="route.path"` (DashboardRouterView.vue)
// means an id change always fully remounts this component, so the key can be
// computed once at setup time rather than re-derived via a watch.
const bitcoinSyncResource = useCachedResource<{ block_height: number; sync_progress: number }>({
key: `app-details:bitcoin-sync:${appId.value}`,
fetcher: (signal) => rpcClient.call<{ block_height: number; sync_progress: number }>({
method: 'bitcoin.getinfo',
signal,
dedup: true,
timeout: 5000,
}),
ttlMs: 30_000, // install/health-state-shaped data, per plan default
immediate: false, // kicked from onMounted, gated on needsBitcoinSync
})
const bitcoinSyncPercent = computed(() => (bitcoinSyncResource.data.value?.sync_progress ?? 0) * 100)
const bitcoinBlockHeight = computed(() => bitcoinSyncResource.data.value?.block_height ?? 0)
const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9)
const credentials = ref<AppCredentialsResponse | null>(null)
const credentialsLoading = ref(false)
const pendingAction = ref<'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null>(null)
async function loadBitcoinSync() {
if (!needsBitcoinSync.value) return
try {
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
method: 'bitcoin.getinfo',
timeout: 5000,
})
bitcoinSyncPercent.value = (btc.sync_progress ?? 0) * 100
bitcoinBlockHeight.value = btc.block_height ?? 0
} catch {
bitcoinSyncPercent.value = 0
bitcoinBlockHeight.value = 0
}
}
async function loadCredentials() {
if (!appId.value) return
credentialsLoading.value = true
try {
// Credential material memory-only (persist: false), never written to
// sessionStorage (D-08 / T-02-01).
const credentialsResource = useCachedResource<AppCredentialsResponse | null>({
key: `app-details:credentials:${appId.value}`,
fetcher: async (signal) => {
const result = await rpcClient.call<AppCredentialsResponse>({
method: 'package.credentials',
params: { app_id: packageKey.value },
signal,
dedup: true,
timeout: 5000,
})
credentials.value = resolveAppCredentials(packageKey.value, result)
} catch {
credentials.value = resolveAppCredentials(packageKey.value, null)
} finally {
credentialsLoading.value = false
return resolveAppCredentials(packageKey.value, result)
},
ttlMs: 30_000,
persist: false,
immediate: false,
})
const credentials = computed(() => credentialsResource.data.value ?? resolveAppCredentials(packageKey.value, null))
const credentialsLoading = computed(() => credentialsResource.loadState.value === 'loading')
// refresh() is unconditional (force-fetch); only call it when the cached
// entry is missing or past its TTL, so a repeat open inside the TTL paints
// from cache with no new RPC.
function loadBitcoinSync() {
if (!needsBitcoinSync.value) return
if (bitcoinSyncResource.data.value === null || bitcoinSyncResource.isStale.value) {
void bitcoinSyncResource.refresh()
}
}
function loadCredentials() {
if (!appId.value) return
if (credentialsResource.data.value === null || credentialsResource.isStale.value) {
void credentialsResource.refresh()
}
}
const pendingAction = ref<'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null>(null)
// Both loaders are independent (bitcoin sync state vs. this app's
// credentials) and were already fire-and-forget here before this plan do
// not "fix" this into an awaited chain, it is already effectively parallel.
onMounted(() => {
loadBitcoinSync()
loadCredentials()
@ -301,6 +326,9 @@ async function stopApp() {
pendingAction.value = 'stop'
try {
await store.stopPackage(appId.value)
// Stopping the app can take its admin credentials offline invalidate
// rather than show a stale "healthy" credentials card (T-02-12).
credentialsResource.invalidate()
} catch (err) {
showActionError(`Failed to stop: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
@ -312,6 +340,9 @@ async function restartApp() {
pendingAction.value = 'restart'
try {
await store.restartPackage(appId.value)
// A restart can rotate credentials/admin URLs invalidate so the next
// read is fresh rather than the pre-restart cache (T-02-12).
credentialsResource.invalidate()
} catch (err) {
showActionError(`Failed to restart: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
@ -340,6 +371,10 @@ async function confirmUninstall(deleteAppData: boolean) {
pendingAction.value = 'uninstall'
try {
await store.uninstallPackage(appId.value, { preserveData: !deleteAppData })
// Invalidate before navigating away the app no longer exists, so its
// cached credentials must not be replayed if this screen is reopened
// before the TTL lapses (T-02-12).
credentialsResource.invalidate()
router.push('/dashboard/apps').catch(() => {})
} catch (err) {
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)

View File

@ -380,6 +380,8 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
import { rpcClient } from '../api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import type { PackageVersionsResponse } from '../api/rpc-client'
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
import { useAppLauncherStore } from '../stores/appLauncher'
import BackButton from '@/components/BackButton.vue'
@ -411,6 +413,26 @@ const electrumxArchiveWarning = 'You need a full archival bitcoin node before do
const appId = computed(() => route.params.id as string)
// Keyed per marketplace app id (D-04). Catalog version metadata is
// near-static, so this can take a longer TTL than the 30s default; it holds
// nothing credential/DID/wallet/tx-history-shaped so persist stays default
// (true). Note per 02-FINDINGS.md: this is the only one of MarketplaceAppDetails's
// captured RPC calls that isn't confounded by the Home-tab-transit artifact
// getCurrentApp() is a synchronous store read (no RPC) and the bitcoin-prune
// check below is a plain fetch(), not an rpcClient call.
const versionsResource = useCachedResource<PackageVersionsResponse>({
key: `app-details:versions:${appId.value}`,
fetcher: (signal) => rpcClient.call<PackageVersionsResponse>({
method: 'package.versions',
params: { id: appId.value },
signal,
dedup: true,
timeout: 15000,
}),
ttlMs: 120_000,
immediate: false,
})
// Web-only apps (no container, just a URL) always treated as "installed"
const isWebOnly = computed(() => {
return !!(app.value?.webUrl && !app.value?.dockerImage)
@ -550,17 +572,20 @@ onMounted(() => {
// Fetch the catalog's selectable versions so the install panel can offer a
// choice (latest pre-selected). Best-effort: on any failure the selector stays
// hidden and install proceeds at the catalog default.
// hidden and install proceeds at the catalog default. Only refetches when the
// cached entry is missing or past its TTL, so a repeat open inside the TTL
// paints from cache with no new RPC.
async function loadInstallVersions() {
installVersions.value = []
try {
const info = await rpcClient.getPackageVersions(appId.value)
if (!info.supportsVersions || info.versions.length < 2) return
installVersions.value = info.versions
selectedInstallVersion.value = info.default || info.versions.find(v => v.default)?.version || info.versions[0]?.version || ''
} catch (err) {
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] loadInstallVersions failed:', err)
if (versionsResource.data.value === null || versionsResource.isStale.value) {
await versionsResource.refresh()
}
const info = versionsResource.data.value
if (!info || !info.supportsVersions || info.versions.length < 2) {
installVersions.value = []
return
}
installVersions.value = info.versions
selectedInstallVersion.value = info.default || info.versions.find(v => v.default)?.version || info.versions[0]?.version || ''
}
async function loadBitcoinPruneStatus() {

View File

@ -0,0 +1,328 @@
// Per-item keyed useCachedResource conversions for secondary screens (D-04,
// plan 02-03). Pins: instant repeat-open from cache, no new RPC inside the
// TTL, per-item key isolation (rendered content, not just call counts),
// TTL-lapse revalidation, and keep-last-value on a rejected background
// refresh (D-07).
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, type Pinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AppDetails from '../AppDetails.vue'
import MarketplaceAppDetails from '../MarketplaceAppDetails.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
getPackageVersions: vi.fn(),
},
}))
let currentRouteParams: Record<string, string> = {}
let currentRouteQuery: Record<string, string> = {}
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn(() => Promise.resolve()), replace: vi.fn() }),
useRoute: () => ({ params: currentRouteParams, query: currentRouteQuery }),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
let currentPackages: Record<string, unknown> = {}
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
packages: currentPackages,
startPackage: vi.fn().mockResolvedValue(undefined),
stopPackage: vi.fn().mockResolvedValue(undefined),
restartPackage: vi.fn().mockResolvedValue(undefined),
updatePackage: vi.fn().mockResolvedValue(undefined),
uninstallPackage: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('@/composables/useMarketplaceApp', () => ({
useMarketplaceApp: () => ({ getCurrentApp: () => currentMarketplaceApp }),
}))
let currentMarketplaceApp: Record<string, unknown> | null = null
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makePkg(overrides: Record<string, unknown> = {}) {
return {
manifest: { id: 'pkg', version: '1.0.0', title: 'Test App' },
state: 'running',
health: 'healthy',
installed: null,
...overrides,
}
}
// A single Pinia instance is shared across the unmount/remount pairs within
// one test — matching production, where the resources store's in-memory
// entries Map is a singleton that survives navigating away from and back to
// a secondary screen (only a full page reload, or clearAll() on logout,
// resets it). Creating a fresh Pinia per mount would wipe persist:false
// entries (credentials) between "visits" and falsely fail the cache-hit
// assertions below.
function mountAppDetails(id: string, packages: Record<string, unknown>, pinia: Pinia) {
currentRouteParams = { id }
currentRouteQuery = {}
currentPackages = packages
return mount(AppDetails, {
global: {
plugins: [pinia],
stubs: { AppHeroSection: true, AppContentSection: true, LndSeedBackup: true, AppsUninstallModal: true },
mocks: { $ver: (v: string) => v },
},
})
}
describe('AppDetails.vue — per-item cached resources (bitcoin sync + credentials)', () => {
beforeEach(() => {
sessionStorage.clear()
vi.mocked(rpcClient.getPackageVersions).mockResolvedValue({
id: 'x', supportsVersions: false, default: null, installedVersion: null,
pinnedVersion: null, autoUpdate: false, versions: [],
})
})
afterEach(() => {
vi.useRealTimers()
})
function credentialsHandler(byId: Record<string, { label: string; value: string }>) {
return vi.fn((req: { method: string; params?: { app_id?: string } }) => {
if (req.method === 'package.credentials') {
const appId = req.params?.app_id ?? ''
const cred = byId[appId]
return Promise.resolve(cred ? { credentials: [cred] } : { credentials: [] })
}
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
return Promise.resolve({})
})
}
it('repeat mount for the same app id inside the TTL issues exactly one credentials fetch total', async () => {
const pinia = createPinia()
const handler = credentialsHandler({ 'app-alpha': { label: 'Password', value: 'alpha-secret' } })
vi.mocked(rpcClient.call).mockImplementation(handler as never)
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await flushPromises()
w1.unmount()
const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await flushPromises()
w2.unmount()
const credCalls = handler.mock.calls.filter((c) => (c[0] as { method: string }).method === 'package.credentials')
expect(credCalls.length).toBe(1)
})
it('mounting for app id alpha then beta issues two credentials fetches and never renders alpha data for beta', async () => {
const pinia = createPinia()
const handler = credentialsHandler({
'app-alpha': { label: 'Password', value: 'alpha-secret' },
'app-beta': { label: 'Password', value: 'beta-secret' },
})
vi.mocked(rpcClient.call).mockImplementation(handler as never)
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await flushPromises()
expect(w1.text()).toContain('alpha-secret')
w1.unmount()
const w2 = mountAppDetails('app-beta', { 'app-beta': makePkg() }, pinia)
await flushPromises()
expect(w2.text()).toContain('beta-secret')
expect(w2.text()).not.toContain('alpha-secret')
w2.unmount()
const credCalls = handler.mock.calls.filter((c) => (c[0] as { method: string }).method === 'package.credentials')
expect(credCalls.length).toBe(2)
})
it('a repeat mount after the TTL lapses shows cached data on the first frame and refetches exactly once more', async () => {
const pinia = createPinia()
vi.useFakeTimers({ shouldAdvanceTime: true })
let credentialCallCount = 0
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
if (req.method === 'package.credentials') {
credentialCallCount++
return Promise.resolve({ credentials: [{ label: 'Password', value: 'alpha-secret' }] })
}
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
return Promise.resolve({})
})
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await flushPromises()
w1.unmount()
vi.advanceTimersByTime(31_000) // past the 30s TTL
// A deferred second fetch lets us observe the cached value on-screen
// before the TTL-triggered revalidate resolves.
const stalled = deferred<{ credentials: { label: string; value: string }[] }>()
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
if (req.method === 'package.credentials') {
credentialCallCount++
return stalled.promise
}
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
return Promise.resolve({})
})
const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await w2.vm.$nextTick()
// Cached value renders before the (TTL-triggered) revalidate resolves.
expect(w2.text()).toContain('alpha-secret')
stalled.resolve({ credentials: [{ label: 'Password', value: 'alpha-secret' }] })
await flushPromises()
w2.unmount()
expect(credentialCallCount).toBe(2)
})
it('a rejected background refresh keeps the previously rendered credentials on screen', async () => {
const pinia = createPinia()
vi.useFakeTimers({ shouldAdvanceTime: true })
const good = credentialsHandler({ 'app-alpha': { label: 'Password', value: 'alpha-secret' } })
vi.mocked(rpcClient.call).mockImplementation(good as never)
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await flushPromises()
expect(w1.text()).toContain('alpha-secret')
w1.unmount()
vi.advanceTimersByTime(31_000)
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
if (req.method === 'package.credentials') return Promise.reject(new Error('offline'))
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
return Promise.resolve({})
})
const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await flushPromises()
// Keep-last-value (D-07): the in-memory cache still shows the previous
// credential even though the revalidate failed.
expect(w2.text()).toContain('alpha-secret')
w2.unmount()
})
it('bitcoin-sync and credentials fetchers for a single mount are issued concurrently, not one after another', async () => {
const pinia = createPinia()
const bitcoinDeferred = deferred<{ block_height: number; sync_progress: number }>()
const credsDeferred = deferred<{ credentials: { label: string; value: string }[] }>()
const calledMethods: string[] = []
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
calledMethods.push(req.method)
if (req.method === 'bitcoin.getinfo') return bitcoinDeferred.promise
if (req.method === 'package.credentials') return credsDeferred.promise
return Promise.resolve({})
})
// 'mempool-electrs' is in BITCOIN_DEPENDENT_APPS and maps to itself via
// ROUTE_TO_PACKAGE_KEY, so this id exercises the bitcoin-sync resource.
const w = mountAppDetails('mempool-electrs', { 'mempool-electrs': makePkg() }, pinia)
await w.vm.$nextTick()
// Both fetchers must already be in flight before either resolves —
// proof neither loader awaited the other.
expect(calledMethods).toContain('bitcoin.getinfo')
expect(calledMethods).toContain('package.credentials')
bitcoinDeferred.resolve({ block_height: 800000, sync_progress: 1 })
credsDeferred.resolve({ credentials: [] })
await flushPromises()
w.unmount()
})
})
describe('MarketplaceAppDetails.vue — per-item cached catalog versions', () => {
beforeEach(() => {
sessionStorage.clear()
currentRouteQuery = {}
})
afterEach(() => {
vi.useRealTimers()
})
function mountMarketplaceDetails(id: string, app: Record<string, unknown> | null) {
currentRouteParams = { id }
currentRouteQuery = {}
currentMarketplaceApp = app
return mount(MarketplaceAppDetails, {
global: {
plugins: [createPinia()],
mocks: { $ver: (v: string) => v },
},
})
}
function makeApp(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
title: `App ${id}`,
description: 'desc',
version: '1.0.0',
dockerImage: `registry/${id}:latest`,
screenshots: [],
...overrides,
}
}
it('repeat mount for the same marketplace app id inside the TTL issues exactly one package.versions fetch', async () => {
const calls: string[] = []
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
calls.push(req.method)
if (req.method === 'package.versions') {
return Promise.resolve({ id: 'demo-app', supportsVersions: false, default: null, installedVersion: null, pinnedVersion: null, autoUpdate: false, versions: [] })
}
return Promise.resolve({})
})
const w1 = mountMarketplaceDetails('demo-app', makeApp('demo-app'))
await flushPromises()
w1.unmount()
const w2 = mountMarketplaceDetails('demo-app', makeApp('demo-app'))
await flushPromises()
w2.unmount()
expect(calls.filter((m) => m === 'package.versions').length).toBe(1)
})
it('mounting for marketplace app id alpha then beta each issue their own versions fetch (distinct per-item keys)', async () => {
const requestedIds: string[] = []
vi.mocked(rpcClient.call).mockImplementation((req: { method: string; params?: { id?: string } }) => {
if (req.method === 'package.versions') {
requestedIds.push(req.params?.id ?? '')
return Promise.resolve({ id: req.params?.id, supportsVersions: false, default: null, installedVersion: null, pinnedVersion: null, autoUpdate: false, versions: [] })
}
return Promise.resolve({})
})
const w1 = mountMarketplaceDetails('mkt-alpha', makeApp('mkt-alpha'))
await flushPromises()
w1.unmount()
const w2 = mountMarketplaceDetails('mkt-beta', makeApp('mkt-beta'))
await flushPromises()
w2.unmount()
expect(requestedIds).toEqual(['mkt-alpha', 'mkt-beta'])
})
})