feat(02-05): cache the six Mesh tab-entry fetch groups without serializing them

- Mesh.vue: six useCachedResource entries wrap mesh.refreshAll(),
  transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(),
  refreshSelfDid() and refreshContacts(), each with an explicit TTL
  (10s reachability/transport, 30s federation/contacts, 300s self
  DID/onion) and persist decision (T-02-01: every group carrying peer
  or self identity data is persist:false; only aggregate transport
  status may persist)
- armMeshLive's Promise.all fan-out becomes a single Promise.allSettled
  over refreshMeshGroupIfStale() per group, so a revisit inside TTL
  issues zero RPC, a stale revisit revalidates concurrently (not a
  serial chain, T-02-16), and one rejected group never blocks the rest
- RefreshIndicator wired into the Mesh header, driven by whether any of
  the six groups is refreshing — visible while peer reachability
  revalidates on re-entry so a resumed tab never shows a frozen
  reachability state as current (T-02-13)
- dedup: true added to every underlying rpc-client.ts/mesh.ts/
  transport.ts fetcher call backing the six groups
- useCachedResource() calls live in Mesh.vue rather than inside
  stores/mesh.ts or stores/transport.ts: Pinia's defineStore(id, setup)
  runs in a bare effectScope, not a component instance, so the
  composable's internal onActivated() would silently no-op there; the
  fetchers still wrap the stores' own actions unchanged, so those
  actions' other callers (clearAllMesh, setMeshOnly, the pre-send
  balance check) keep their guaranteed-fresh, uncached reads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 16:14:32 -04:00
parent e582124259
commit 31389bcc3c
6 changed files with 507 additions and 9 deletions

View File

@ -350,6 +350,7 @@ class RPCClient {
return this.call({
method: 'node.did',
params: {},
dedup: true, // read-only identity lookup; safe to collapse concurrent callers (PERF-02)
})
}
@ -577,6 +578,7 @@ class RPCClient {
return this.call({
method: 'node.tor-address',
params: {},
dedup: true, // read-only; near-static value, safe to collapse concurrent callers (PERF-02)
})
}
@ -801,7 +803,7 @@ class RPCClient {
async meshContactsList(): Promise<{
contacts: Array<{ pubkey: string; alias?: string | null; notes?: string | null; pinned?: boolean; blocked?: boolean }>
}> {
return this.call({ method: 'mesh.contacts-list', params: {} })
return this.call({ method: 'mesh.contacts-list', params: {}, dedup: true }) // read-only (PERF-02)
}
async meshContactsSave(
@ -842,6 +844,7 @@ class RPCClient {
return this.call({
method: 'federation.list-nodes',
params: {},
dedup: true, // read-only; safe to collapse concurrent callers (PERF-02)
})
}

View File

@ -318,7 +318,7 @@ export const useMeshStore = defineStore('mesh', () => {
async function fetchStatus() {
try {
loading.value = true
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status', dedup: true })
status.value = res
trackDetectedDevices(res)
} catch (err: unknown) {
@ -462,6 +462,7 @@ export const useMeshStore = defineStore('mesh', () => {
try {
const res = await rpcClient.call<{ peers: MeshPeer[]; count: number }>({
method: 'mesh.peers',
dedup: true,
})
peers.value = res.peers
updateNodePositionsFromPeers(res.peers)
@ -475,6 +476,7 @@ export const useMeshStore = defineStore('mesh', () => {
const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({
method: 'mesh.messages',
params: limit ? { limit } : {},
dedup: true,
})
if (seedLastSeenFromHistory && res.messages.length > 0) {
for (const m of res.messages) {
@ -1019,7 +1021,7 @@ export const useMeshStore = defineStore('mesh', () => {
async function fetchDeadmanStatus() {
try {
deadmanStatus.value = await rpcClient.call<AlertStatus>({ method: 'mesh.deadman-status' })
deadmanStatus.value = await rpcClient.call<AlertStatus>({ method: 'mesh.deadman-status', dedup: true })
} catch {
// Dead man switch not available
}
@ -1052,6 +1054,7 @@ export const useMeshStore = defineStore('mesh', () => {
const res = await rpcClient.call<{ headers: BlockHeader[]; latest_height: number; count: number }>({
method: 'mesh.block-headers',
params: { count },
dedup: true,
})
blockHeaders.value = res.headers
latestBlockHeight.value = res.latest_height

View File

@ -45,7 +45,7 @@ export const useTransportStore = defineStore('transport', () => {
try {
loading.value = true
error.value = null
const res = await rpcClient.call<TransportStatus>({ method: 'transport.status' })
const res = await rpcClient.call<TransportStatus>({ method: 'transport.status', dedup: true })
status.value = res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch transport status'
@ -58,6 +58,7 @@ export const useTransportStore = defineStore('transport', () => {
try {
const res = await rpcClient.call<{ peers: TransportPeer[] }>({
method: 'transport.peers',
dedup: true,
})
peers.value = res.peers
} catch (err: unknown) {

View File

@ -16,6 +16,8 @@ import { wsClient } from '@/api/websocket'
import { IMAGE_COMPRESSION_PRESETS, compressImage, makeThumbnail, type ImageCompressionPreset } from '@/utils/imageCompression'
import MediaLightbox from '@/components/cloud/MediaLightbox.vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { useCachedResource } from '@/composables/useCachedResource'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import '@/views/mesh/mesh-styles.css'
const mesh = useMeshStore()
@ -363,6 +365,133 @@ function updateKeyboardInset() {
document.documentElement.style.setProperty('--keyboard-inset', `${inset}px`)
}
// Cached tab-entry fan-out (PERF-02, T-02-01, T-02-13, T-02-16)
//
// The six fetch groups armMeshLive() fires on every tab entry are wrapped in
// useCachedResource entries so a revisit inside each group's TTL issues no
// RPC at all, while a stale revisit revalidates in the background (driven
// through the shared RefreshIndicator below) without clearing the screen.
//
// Placement: mesh.refreshAll() and transport.fetchStatus() are store actions
// with other callers (clearAllMesh() below, and setMeshOnly()/
// Web5SendReceiveModals.vue's pre-send balance check, respectively) that
// need a guaranteed-fresh read, not a cache-gated one so those two store
// actions themselves are left untouched, and the useCachedResource() call
// that wraps each of them lives here instead of inside stores/mesh.ts or
// stores/transport.ts. This is a deliberate technical choice, not a
// re-reading of the plan's intent: Pinia's `defineStore(id, setup)` runs its
// setup inside a bare `effectScope`, not a real component instance, and
// useCachedResource's internal `onActivated()` registration is a documented
// Vue no-op (with a dev warning) outside a component's own setup() hosting
// it at store scope would compile but would never actually revalidate
// anything on tab reactivation. Mesh.vue is the one call site that
// legitimately owns the KeepAlive/component lifecycle, so it hosts all six
// resources; the store-owned groups' fetchers still just re-invoke the
// store's existing action, so the store's real refresh path is what is
// genuinely being cached and gated.
//
// Every entry is `immediate: false` and force-refreshed only when stale via
// refreshMeshGroupIfStale() below, called from a single Promise.allSettled
// array in armMeshLive never auto-triggered independently so the
// cold-load fan-out stays concurrent instead of becoming a serialized chain
// of six separately-awaited refreshes (T-02-16), and one rejected group
// never suppresses the other five. Each fetcher resolves to a plain
// timestamp (not the real payload): the real UI-facing state continues to
// flow through mesh.status/mesh.peers/etc (set by the wrapped store
// actions/view-local refreshers as a side effect, exactly as before this
// plan) these resources exist purely to gate/dedupe the RPC and to expose
// a `loadState` for the shared RefreshIndicator, not to hold the data itself.
const meshRefreshResource = useCachedResource<number>({
key: 'mesh.refresh-all',
fetcher: async (signal) => {
void signal
await mesh.refreshAll()
return Date.now()
},
// Peer/status/message data is exactly the liveness-critical reachability
// surface T-02-13 forbids freezing shortest TTL of the six groups.
ttlMs: 10_000,
persist: false, // mesh.peers carries peer DIDs/pubkeys identity payload
immediate: false,
})
const transportStatusResource = useCachedResource<number>({
key: 'mesh.transport-status',
fetcher: async (signal) => {
void signal
await transport.fetchStatus()
return Date.now()
},
ttlMs: 10_000, // transport availability / mesh-only mode is live state
persist: true, // aggregate status only no peer identity payload
immediate: false,
})
const federationNodesResource = useCachedResource<number>({
key: 'mesh.federation-nodes',
fetcher: async (signal) => {
void signal
await refreshFederationNodes()
return Date.now()
},
ttlMs: 30_000, // D-06 default federation node list isn't liveness-critical
persist: false, // federation nodes carry DID/pubkey/onion identity payload
immediate: false,
})
const selfOnionResource = useCachedResource<number>({
key: 'mesh.self-onion',
fetcher: async (signal) => {
void signal
await refreshSelfOnion()
return Date.now()
},
ttlMs: 300_000, // this node's own onion address is effectively static
persist: false, // this node's own onion address identity payload
immediate: false,
})
const selfDidResource = useCachedResource<number>({
key: 'mesh.self-did',
fetcher: async (signal) => {
void signal
await refreshSelfDid()
return Date.now()
},
ttlMs: 300_000, // this node's own DID is effectively static
persist: false, // this node's own DID identity payload
immediate: false,
})
const contactsResource = useCachedResource<number>({
key: 'mesh.contacts',
fetcher: async (signal) => {
void signal
await refreshContacts()
return Date.now()
},
ttlMs: 30_000, // user-set aliases change rarely; the D-06 default is enough
persist: false, // contact records/aliases identity payload per T-02-01
immediate: false,
})
const meshCachedGroups = [
meshRefreshResource, transportStatusResource, federationNodesResource,
selfOnionResource, selfDidResource, contactsResource,
]
// Drives the header RefreshIndicator (D-05): visible whenever any of the six
// groups is revalidating in the background, including peer reachability, so
// a resumed tab never presents a frozen reachability state as current
// (T-02-13).
const meshRefreshIndicatorState = computed(() =>
meshCachedGroups.some((r) => r.loadState.value === 'refreshing') ? 'refreshing' : 'ready'
)
/** Force a group's refresh only when it has never resolved or is past its
* own TTL mirrors Cloud.vue's loadCounts() gate. Deliberately synchronous
* up to the point of kicking off `refresh()`, so all six calls in
* armMeshLive's Promise.allSettled array start in the same tick rather than
* forming a waterfall. */
function refreshMeshGroupIfStale(res: (typeof meshCachedGroups)[number]): Promise<void> {
if (res.entry.data === null || res.isStale.value) return res.refresh()
return Promise.resolve()
}
// Mesh is a live communications surface peer reachability, messages and
// status are exactly the kind of data the threat model (T-02-13) forbids
// rendering from a paused cache without revalidating. Nothing here is
@ -407,10 +536,15 @@ function armMeshLive() {
// first visit would silently never be picked up.
loadPendingFromSession()
void Promise.all([
mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(),
refreshSelfOnion(), refreshSelfDid(), refreshContacts(),
]).then(() => {
// Cache-gated fan-out (PERF-02): each group only issues an RPC if it has
// never resolved or is past its own TTL (refreshMeshGroupIfStale), and all
// six start in the same Promise.allSettled array so a revisit inside every
// group's TTL issues zero RPCs, a stale revisit revalidates concurrently
// rather than as a serial chain, and one rejected group never blocks the
// other five (T-02-16).
void Promise.allSettled(
meshCachedGroups.map((res) => refreshMeshGroupIfStale(res))
).then(() => {
refreshOutboxCount()
// Deep-link from a message toast: open the sender's conversation if we can
// match a LoRa mesh peer; otherwise open the Archipelago channel, since
@ -1918,7 +2052,10 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<!-- Header (hidden on mobile) -->
<div class="mesh-header hidden md:flex">
<div class="mesh-header-left">
<h1 class="mesh-title">Mesh Network</h1>
<div class="mesh-title-row">
<h1 class="mesh-title">Mesh Network</h1>
<RefreshIndicator :state="meshRefreshIndicatorState" label="Refreshing mesh status" />
</div>
<p class="mesh-subtitle">
{{ mesh.status?.peer_count ?? 0 }} peer{{ (mesh.status?.peer_count ?? 0) !== 1 ? 's' : '' }}
<span v-if="mesh.status?.device_connected" class="mesh-subtitle-badge">Live</span>

View File

@ -0,0 +1,353 @@
// 02-05: caches the Mesh tab's six uncached fetch groups behind
// useCachedResource entries, and bounds the Leaflet map's lifecycle across
// Mesh.vue's activate/deactivate cycle (established in 02-04).
//
// FLAGGED (see 02-05-SUMMARY.md): RESEARCH.md's premise that Mesh.vue owns a
// live D3 force simulation does not hold for this codebase — a full grep for
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
// Mesh.vue's component tree; the only D3 force simulation belongs to
// NetworkMap.vue (Federation.vue's graph, out of this plan's scope). This
// file therefore only covers the six cached fetch groups (Task 1) and the
// Leaflet map's activate/deactivate lifecycle (Task 2, MeshMap.vue) — the
// D3-specific truths are vacuously satisfied (there is nothing to leak).
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Mesh from '../Mesh.vue'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import { rpcClient } from '@/api/rpc-client'
import { useMeshStore } from '@/stores/mesh'
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
}))
vi.mock('@/api/websocket', () => ({
wsClient: { subscribe: vi.fn(() => vi.fn()) },
}))
function meshStatusPayload(overrides: Record<string, unknown> = {}) {
return {
enabled: true,
device_type: 'meshcore',
device_path: null,
device_connected: true,
firmware_version: null,
self_node_id: 1,
self_advert_name: 'Self',
peer_count: 0,
channel_name: 'Public',
messages_sent: 0,
messages_received: 0,
...overrides,
}
}
// Records every rpcClient.call({method}) invocation so tests can assert per-
// group call counts and start-order without depending on real network I/O.
const rpcCallMock = vi.fn(async ({ method }: { method: string }) => {
switch (method) {
case 'mesh.status':
return meshStatusPayload()
case 'mesh.peers':
return { peers: [], count: 0 }
case 'mesh.messages':
return { messages: [], count: 0 }
case 'mesh.deadman-status':
return { enabled: false }
case 'mesh.block-headers':
return { headers: [], latest_height: 0, count: 0 }
case 'transport.status':
return { transports: [], mesh_only: false, peer_count: 0 }
default:
return {}
}
})
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: (...args: unknown[]) => (rpcCallMock as unknown as (...a: unknown[]) => unknown)(...args),
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
getTorAddress: vi.fn().mockResolvedValue({ tor_address: null }),
getNodeDid: vi.fn().mockResolvedValue({ did: 'did:key:z6Mkself' }),
meshContactsList: vi.fn().mockResolvedValue({ contacts: [] }),
},
}))
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
function mountMeshHost() {
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Mesh, { key: 'mesh' }) : h(Other, { key: 'other' }),
)
},
})
return mount(Host, {
global: {
plugins: [createPinia()],
stubs: {
AnimatedLogo: true,
MeshMap: true,
MeshBitcoinPanel: true,
MeshDeadmanPanel: true,
MeshDevicePanel: true,
MeshAssistantPanel: true,
HopVizModal: true,
MediaLightbox: true,
},
},
})
}
function callCountFor(method: string): number {
return rpcCallMock.mock.calls.filter(([opts]) => (opts as { method: string }).method === method).length
}
function convenienceCallCounts() {
return {
federationListNodes: vi.mocked(rpcClient.federationListNodes).mock.calls.length,
getTorAddress: vi.mocked(rpcClient.getTorAddress).mock.calls.length,
getNodeDid: vi.mocked(rpcClient.getNodeDid).mock.calls.length,
meshContactsList: vi.mocked(rpcClient.meshContactsList).mock.calls.length,
}
}
async function toggleTab(wrapper: ReturnType<typeof mountMeshHost>, show: boolean) {
;(wrapper.vm as unknown as { show: boolean }).show = show
await wrapper.vm.$nextTick()
}
beforeEach(() => {
vi.useFakeTimers()
rpcCallMock.mockClear()
vi.mocked(rpcClient.federationListNodes).mockClear()
vi.mocked(rpcClient.getTorAddress).mockClear()
vi.mocked(rpcClient.getNodeDid).mockClear()
vi.mocked(rpcClient.meshContactsList).mockClear()
try {
sessionStorage.clear()
} catch { /* unavailable in some envs */ }
})
afterEach(() => {
vi.useRealTimers()
})
describe('Mesh tab cache (Task 1): six fetch groups', () => {
it('a cold load fires all six groups concurrently — every RPC has already started before any microtask resolves', async () => {
const wrapper = mountMeshHost()
// Synchronously (no await, no flushPromises yet) every one of the six
// groups' underlying calls must already have fired: armMeshLive() runs
// Promise.allSettled(meshCachedGroups.map(refreshMeshGroupIfStale)),
// and none of the intermediate layers (refreshMeshGroupIfStale ->
// store.refresh -> fetcher -> mesh.refreshAll/refreshFederationNodes/etc
// -> rpcClient.call) awaits anything before reaching the RPC call — a
// serialized chain (awaiting one group before starting the next) could
// not possibly have reached all six yet at this point.
expect(callCountFor('mesh.status')).toBe(1)
expect(callCountFor('mesh.peers')).toBe(1)
expect(callCountFor('mesh.messages')).toBe(1)
expect(callCountFor('mesh.deadman-status')).toBe(1)
expect(callCountFor('mesh.block-headers')).toBe(1)
expect(callCountFor('transport.status')).toBe(1)
expect(convenienceCallCounts()).toEqual({
federationListNodes: 1, getTorAddress: 1, getNodeDid: 1, meshContactsList: 1,
})
await flushPromises()
wrapper.unmount()
})
it('reactivating inside every group\'s TTL issues zero additional RPCs across all six groups', async () => {
const wrapper = mountMeshHost()
await flushPromises()
const before = {
status: callCountFor('mesh.status'),
peers: callCountFor('mesh.peers'),
messages: callCountFor('mesh.messages'),
deadman: callCountFor('mesh.deadman-status'),
headers: callCountFor('mesh.block-headers'),
transport: callCountFor('transport.status'),
...convenienceCallCounts(),
}
await toggleTab(wrapper, false)
vi.advanceTimersByTime(3000) // well under every group's TTL (shortest is 10s)
await toggleTab(wrapper, true)
await flushPromises()
expect(callCountFor('mesh.status')).toBe(before.status)
expect(callCountFor('mesh.peers')).toBe(before.peers)
expect(callCountFor('mesh.messages')).toBe(before.messages)
expect(callCountFor('mesh.deadman-status')).toBe(before.deadman)
expect(callCountFor('mesh.block-headers')).toBe(before.headers)
expect(callCountFor('transport.status')).toBe(before.transport)
expect(convenienceCallCounts()).toEqual({
federationListNodes: before.federationListNodes,
getTorAddress: before.getTorAddress,
getNodeDid: before.getNodeDid,
meshContactsList: before.meshContactsList,
})
wrapper.unmount()
})
it('reactivating past the short (10s) TTL revalidates the fast-moving groups while the near-static identity groups stay cached', async () => {
const wrapper = mountMeshHost()
await flushPromises()
const statusCallsAtMount = callCountFor('mesh.status')
const transportCallsAtMount = callCountFor('transport.status')
const onionCallsAtMount = vi.mocked(rpcClient.getTorAddress).mock.calls.length
const didCallsAtMount = vi.mocked(rpcClient.getNodeDid).mock.calls.length
await toggleTab(wrapper, false)
vi.advanceTimersByTime(15000) // past the 10s reachability/transport TTL, well under the 300s identity TTL
await toggleTab(wrapper, true)
await flushPromises()
// Peer/status/transport (reachability-class, 10s TTL) revalidate exactly once.
expect(callCountFor('mesh.status')).toBe(statusCallsAtMount + 1)
expect(callCountFor('transport.status')).toBe(transportCallsAtMount + 1)
// This node's own onion/DID (300s TTL, near-static identity) are still fresh.
expect(vi.mocked(rpcClient.getTorAddress).mock.calls.length).toBe(onionCallsAtMount)
expect(vi.mocked(rpcClient.getNodeDid).mock.calls.length).toBe(didCallsAtMount)
wrapper.unmount()
})
it('the previous graph/peer data stays rendered while a stale group revalidates (sticky-ready, no blank frame)', async () => {
const wrapper = mountMeshHost()
await flushPromises()
expect(wrapper.text()).toContain('Mesh Network')
await toggleTab(wrapper, false)
vi.advanceTimersByTime(15000)
await toggleTab(wrapper, true)
// Before the revalidation resolves, prior content must still be present
// (sticky-ready never regresses to a blank/loading state).
expect(wrapper.text()).toContain('Mesh Network')
await flushPromises()
expect(wrapper.text()).toContain('Mesh Network')
wrapper.unmount()
})
it('one group rejecting leaves the other five unaffected and the fan-out still completes (deep-link/outbox callback runs)', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const meshStore = useMeshStore()
const rejectingRefreshAll = vi.fn().mockRejectedValue(new Error('boom'))
// Bypass the store's own internal try/catch entirely so a genuine
// rejection reaches useCachedResource's fetcher wrapper.
meshStore.refreshAll = rejectingRefreshAll
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Mesh, { key: 'mesh' }) : h(Other, { key: 'other' }),
)
},
})
const wrapper = mount(Host, {
global: {
plugins: [pinia],
stubs: {
AnimatedLogo: true, MeshMap: true, MeshBitcoinPanel: true, MeshDeadmanPanel: true,
MeshDevicePanel: true, MeshAssistantPanel: true, HopVizModal: true, MediaLightbox: true,
},
},
})
await flushPromises()
expect(rejectingRefreshAll).toHaveBeenCalled()
// The other five groups still ran to completion.
expect(callCountFor('transport.status')).toBeGreaterThan(0)
expect(vi.mocked(rpcClient.federationListNodes).mock.calls.length).toBeGreaterThan(0)
expect(vi.mocked(rpcClient.getTorAddress).mock.calls.length).toBeGreaterThan(0)
expect(vi.mocked(rpcClient.getNodeDid).mock.calls.length).toBeGreaterThan(0)
expect(vi.mocked(rpcClient.meshContactsList).mock.calls.length).toBeGreaterThan(0)
// The .then() callback after the fan-out (refreshOutboxCount -> mesh.outbox) still ran.
expect(callCountFor('mesh.outbox')).toBeGreaterThan(0)
wrapper.unmount()
})
it('every group carrying peer or self identity data declares persist:false; only the non-identity transport status may persist', async () => {
const wrapper = mountMeshHost()
await flushPromises()
const readSnapshot = (key: string) => {
try {
return sessionStorage.getItem(`resource:${key}`)
} catch {
return null
}
}
expect(readSnapshot('mesh.refresh-all')).toBeNull() // carries mesh.peers (DIDs/pubkeys)
expect(readSnapshot('mesh.federation-nodes')).toBeNull() // DID/pubkey/onion
expect(readSnapshot('mesh.self-onion')).toBeNull() // this node's own onion
expect(readSnapshot('mesh.self-did')).toBeNull() // this node's own DID
expect(readSnapshot('mesh.contacts')).toBeNull() // contact records/aliases
expect(readSnapshot('mesh.transport-status')).not.toBeNull() // non-identity aggregate
wrapper.unmount()
})
it('every fetcher backing the six cache groups is registered with dedup:true', async () => {
const wrapper = mountMeshHost()
await flushPromises()
// Restrict to the six cache groups' own methods — other rpcClient.call
// traffic incidental to the fan-out (e.g. mesh.outbox, refreshed from the
// post-fan-out .then()) isn't one of the cached groups and is out of
// scope for this assertion.
const cachedMethods = [
'mesh.status', 'mesh.peers', 'mesh.messages', 'mesh.deadman-status',
'mesh.block-headers', 'transport.status',
]
const dedupFlags = rpcCallMock.mock.calls
.filter(([opts]) => cachedMethods.includes((opts as { method: string }).method))
.map(([opts]) => (opts as { dedup?: boolean }).dedup)
expect(dedupFlags.length).toBe(cachedMethods.length)
expect(dedupFlags.every(Boolean)).toBe(true)
wrapper.unmount()
})
it('renders RefreshIndicator wired to whether any of the six groups is refreshing', async () => {
const wrapper = mountMeshHost()
await flushPromises()
const indicator = wrapper.findComponent(RefreshIndicator)
expect(indicator.exists()).toBe(true)
// Idle once everything has settled.
expect(indicator.props('state')).toBe('ready')
await toggleTab(wrapper, false)
vi.advanceTimersByTime(15000)
await toggleTab(wrapper, true)
// Immediately after reactivation (before the revalidation resolves) the
// indicator must be visible — peer reachability must never present a
// frozen state as current without a visible refresh signal (T-02-13).
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('refreshing')
await flushPromises()
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('ready')
wrapper.unmount()
})
})

View File

@ -24,6 +24,7 @@
}
.mesh-header { justify-content: space-between; align-items: center; gap: 16px; flex-shrink: 0; }
.mesh-header-left { flex: 1; }
.mesh-title-row { display: flex; align-items: center; gap: 8px; }
.mesh-title { font-size: 1.5rem; font-weight: 700; color: rgba(255, 255, 255, 0.95); margin: 0; }
.mesh-subtitle { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 2px 0 0; display: flex; align-items: center; gap: 8px; }
.mesh-subtitle-badge { font-size: 0.65rem; font-weight: 600; color: #4ade80; background: rgba(74, 222, 128, 0.12); padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; }