feat(02-02): tracer tab refreshes silently and every side effect is placed
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m32s

Puts Marketplace.vue's data on the cache and adds the subtle background-
refresh indicator D-05 calls for:

- RefreshIndicator.vue: presentational, state-driven (ResourceLoadState).
  Renders nothing for ready/idle/loading — a first load is the view's own
  skeleton's job, not this component's — and a role="status"
  aria-live="polite" spinner only while refreshing. A fixed-size outer slot
  keeps its appearance/disappearance from ever shifting layout.
- Marketplace.vue: loadCommunityMarketplace()/loadBitcoinPruneStatus() move
  onto useCachedResource behind shared keys app-catalog (300s TTL, persist —
  near-static catalog, D-06 discretion) and bitcoin.prune-status (default
  30s TTL, persist — both non-sensitive/small per T-02-01). app-catalog is a
  shared key so Discover.vue's identical loader picks up the same entry
  without its own conversion in 02-04. Error handling is keep-last-value
  (D-07): a rejected refresh sets the existing communityError banner ref,
  never a toast, and prior app cards stay on screen.
- Side-effect audit (the precedent 02-04 repeats across remaining tabs):
  marketplaceAnimationDone is genuinely once-per-session intro state, stays
  in onMounted; the two data loads needed no onMounted/onActivated hook of
  their own at all — useCachedResource's internal onActivated (wired in
  Task 1) already revalidates them on every kept-alive tab re-entry,
  staleness-gated so a quick revisit issues no fetch. No interval,
  subscription or window listener exists in this view, so no onDeactivated
  teardown was needed either.
- Tests: RefreshIndicator's full render-nothing/render-something matrix
  added to keepAliveTabs.test.ts (no router dependency, safe to colocate).
  The D-07 rejected-refresh-keeps-content-and-no-toast test mounts
  Marketplace.vue itself but lives in a new file,
  views/__tests__/MarketplaceRefresh.test.ts, following the in-repo
  CloudPeersRefresh.test.ts mount-the-view pattern — vi.mock('vue-router')
  is hoisted file-wide, so colocating it in keepAliveTabs.test.ts would
  clobber that file's real createRouter/createMemoryHistory used by the
  DashboardRouterView tests (Rule 3 auto-fix; deviation from the plan's
  literal single-test-file file list, documented here for the SUMMARY).
  Marketplace.vue gains a defineExpose({ loadCommunityMarketplace,
  loadBitcoinPruneStatus }) for tests, mirroring Cloud.vue's existing
  defineExpose({ loadPeers }).

Full suite (87 files / 709 tests), type-check and build all green; the
built Marketplace chunk carries refresh-indicator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 07:43:34 -04:00
parent 385c9d866e
commit a9a20039eb
4 changed files with 233 additions and 24 deletions

View File

@ -0,0 +1,60 @@
<template>
<div class="refresh-indicator-slot" aria-hidden="false">
<div
v-if="state === 'refreshing'"
class="refresh-indicator"
role="status"
aria-live="polite"
>
<span class="refresh-indicator-spinner" aria-hidden="true" />
<span class="sr-only">{{ label ?? 'Refreshing…' }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { ResourceLoadState } from '@/stores/resources'
// Subtle background-refresh indicator (D-05): visible only while a cached
// resource is revalidating behind already-rendered content. A first load
// is the view's own skeleton's job, not this component's 'loading'
// intentionally renders nothing here, same as 'ready'/'idle'. No stale-age
// text or "last updated" badge (D-05 rules those out).
defineProps<{
state: ResourceLoadState
label?: string
}>()
</script>
<style scoped>
/* Fixed-size outer slot so the indicator appearing/disappearing never
shifts surrounding layout callers can drop this inline without also
reserving space themselves. */
.refresh-indicator-slot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.refresh-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
}
.refresh-indicator-spinner {
width: 0.875rem;
height: 0.875rem;
border: 2px solid rgba(255, 255, 255, 0.15);
border-top-color: rgba(255, 255, 255, 0.55);
border-radius: 50%;
animation: refresh-indicator-spin 0.8s linear infinite;
}
@keyframes refresh-indicator-spin {
to { transform: rotate(360deg); }
}
</style>

View File

@ -57,6 +57,7 @@
:aria-label="t('marketplace.searchApps')"
class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
</div>
<!-- Mobile: categories + search (tabs handled by Dashboard.vue header) -->
@ -69,6 +70,7 @@
<div class="flex items-center gap-2 mb-3">
<span class="discover-terminal-tag">discover</span>
<h1 class="text-lg font-bold text-white">App Store</h1>
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
</div>
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
<button
@ -171,6 +173,8 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
import { useToast } from '@/composables/useToast'
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
import { useCachedResource } from '@/composables/useCachedResource'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
import {
@ -231,12 +235,40 @@ watch(() => route.query.category, (category) => {
}
})
// Community marketplace state
const loadingCommunity = ref(false)
const communityError = ref('')
const communityApps = ref<MarketplaceApp[]>([])
// Community marketplace state cached (D-09/D-06: near-static catalog, long
// TTL) behind a shared key so Discover.vue's identical loader picks up the
// same cache entry without its own conversion (plan 02-04). Non-sensitive
// and small, so it persists across reloads.
const catalogResource = useCachedResource<MarketplaceApp[]>({
key: 'app-catalog',
fetcher: async () => getCuratedAppList(),
ttlMs: 300_000,
persist: true,
})
const communityApps = computed(() => catalogResource.data.value ?? [])
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
// Keep-last-value error banner (D-07): a failed background refresh never
// raises a toast, it only surfaces here content stays on screen either way.
const communityError = computed(() => catalogResource.error.value ?? '')
interface BitcoinStatusResponse {
blockchain_info?: { pruned?: boolean }
}
// Prune status the default TTL (30s) matches the rest of the app; also
// non-sensitive and small, so it persists too.
const pruneStatusResource = useCachedResource<BitcoinStatusResponse | null>({
key: 'bitcoin.prune-status',
fetcher: async (signal) => {
const res = await fetch('/bitcoin-status', { credentials: 'include', signal })
if (!res.ok) throw new Error(`bitcoin-status responded ${res.status}`)
return res.json()
},
persist: true,
})
const searchQuery = ref('')
const bitcoinPruned = ref(false)
const bitcoinPruned = computed(() => pruneStatusResource.data.value?.blockchain_info?.pruned === true)
const marketplaceHeaderRef = ref<HTMLElement | null>(null)
const marketplacePrimaryRef = ref<HTMLElement | null>(null)
const marketplaceCategoryProbeRef = ref<HTMLElement | null>(null)
@ -374,23 +406,21 @@ function launchInstalledApp(app: MarketplaceApp) {
appLauncher.openSession(app.id)
}
// onMounted fires exactly once for the lifetime of this kept-alive instance
// (D-01/plan 02-01). marketplaceAnimationDone is genuinely once-per-session
// intro state, so it stays here. The catalog and prune-status loads used to
// live here too, fetching fresh on every remount; they're now cache-gated
// useCachedResource entries whose own onActivated revalidates them on every
// tab re-entry (staleness-checked, so a quick revisit issues no RPC) they
// need no per-view mount/activation hook of their own.
onMounted(() => {
marketplaceAnimationDone = true
if (communityApps.value.length === 0 && !loadingCommunity.value) {
loadCommunityMarketplace()
}
loadBitcoinPruneStatus()
})
async function loadBitcoinPruneStatus() {
try {
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
if (!res.ok) return
const status = await res.json()
bitcoinPruned.value = status?.blockchain_info?.pruned === true
} catch (e) {
if (import.meta.env.DEV) console.warn('[Marketplace] Bitcoin prune status unavailable:', e)
}
/** Force-refresh the Bitcoin prune-status cache entry (deduped with any
* in-flight refresh, per useCachedResource). Exposed for tests. */
function loadBitcoinPruneStatus() {
return pruneStatusResource.refresh()
}
function installBlockedReason(appId: string): string | undefined {
@ -399,12 +429,10 @@ function installBlockedReason(appId: string): string | undefined {
return electrumxArchiveWarning
}
async function loadCommunityMarketplace() {
loadingCommunity.value = true
communityError.value = ''
if (import.meta.env.DEV) console.log('Loading Docker-based app marketplace')
communityApps.value = getCuratedAppList()
loadingCommunity.value = false
/** Force-refresh the app-catalog cache entry (used by the Retry button and
* exposed for tests). */
function loadCommunityMarketplace() {
return catalogResource.refresh()
}
function viewAppDetails(app: MarketplaceApp) {
@ -516,6 +544,10 @@ async function installCommunityApp(app: MarketplaceApp) {
}
}
// Exposed for tests only (mirrors Cloud.vue's `defineExpose({ loadPeers })`):
// lets a test trigger a background refresh directly without waiting on TTL
// staleness or a real KeepAlive round-trip.
defineExpose({ loadCommunityMarketplace, loadBitcoinPruneStatus })
</script>
<style scoped>

View File

@ -0,0 +1,92 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Marketplace from '../Marketplace.vue'
// Mirrors the CloudPeersRefresh.test.ts pattern (in-repo convention for
// mounting a view directly with its heavier deps mocked at the module
// boundary) — kept in its own file rather than keepAliveTabs.test.ts because
// vi.mock('vue-router', ...) is hoisted file-wide and would otherwise
// clobber that file's real createRouter/createMemoryHistory imports used by
// the DashboardRouterView tests (Rule 3 auto-fix).
const routerPushMock = vi.fn()
const toastErrorMock = vi.fn()
const toastInfoMock = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ push: routerPushMock, replace: vi.fn().mockResolvedValue(undefined) }),
useRoute: () => ({ query: {} }),
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ data: {}, hasLoadedInitialData: true }),
}))
vi.mock('@/stores/server', () => ({
useServerStore: () => ({
installingApps: new Map(),
setInstallProgress: vi.fn(),
clearInstallProgress: vi.fn(),
}),
}))
vi.mock('@/stores/appLauncher', () => ({
useAppLauncherStore: () => ({ openSession: vi.fn() }),
}))
vi.mock('@/composables/useMarketplaceApp', () => ({
useMarketplaceApp: () => ({ setCurrentApp: vi.fn() }),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
marketplaceDiscover: vi.fn().mockResolvedValue({ apps: [] }),
},
}))
describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
routerPushMock.mockClear()
toastErrorMock.mockClear()
toastInfoMock.mockClear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('keeps the curated catalog on screen and raises no toast when the prune-status background refresh rejects', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ blockchain_info: { pruned: false } }),
}))
const wrapper = mount(Marketplace, { global: { plugins: [createPinia()] } })
await flushPromises()
expect(wrapper.findAll('.glass-card').length).toBeGreaterThan(0)
vi.mocked(fetch).mockRejectedValueOnce(new Error('node unreachable'))
await (wrapper.vm as unknown as { loadBitcoinPruneStatus: () => Promise<void> }).loadBitcoinPruneStatus()
await flushPromises()
// Prior catalog content is still rendered — a background refresh failure
// on an unrelated resource never wipes the view.
expect(wrapper.findAll('.glass-card').length).toBeGreaterThan(0)
expect(toastErrorMock).not.toHaveBeenCalled()
expect(toastInfoMock).not.toHaveBeenCalled()
wrapper.unmount()
})
})

View File

@ -4,6 +4,7 @@ import { defineComponent, h, onActivated, onMounted } from 'vue'
import { beforeEach, describe, expect, it } from 'vitest'
import DashboardRouterView from '../DashboardRouterView.vue'
import { shouldKeepAlive } from '../keepAliveRoutes'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
let mountCount = 0
let activateCount = 0
@ -101,3 +102,27 @@ describe('DashboardRouterView keep-alive behavior', () => {
expect(shouldKeepAlive({ path: '/dashboard/marketplace/abc' })).toBe(false)
})
})
describe('RefreshIndicator', () => {
it('renders nothing for ready, idle and loading, and a labeled status element for refreshing', () => {
const states = ['ready', 'idle', 'loading'] as const
for (const state of states) {
const wrapper = mount(RefreshIndicator, { props: { state } })
expect(wrapper.find('[role="status"]').exists()).toBe(false)
wrapper.unmount()
}
const refreshing = mount(RefreshIndicator, { props: { state: 'refreshing', label: 'Refreshing things' } })
const status = refreshing.find('[role="status"]')
expect(status.exists()).toBe(true)
expect(status.attributes('aria-live')).toBe('polite')
expect(refreshing.text()).toContain('Refreshing things')
refreshing.unmount()
})
it('falls back to a non-empty default accessible label when none is given', () => {
const wrapper = mount(RefreshIndicator, { props: { state: 'refreshing' } })
expect(wrapper.find('[role="status"]').text().length).toBeGreaterThan(0)
wrapper.unmount()
})
})