diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 9e062f6b..ce50ee6f 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -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) }) } diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 68ea0819..a7ddc846 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -318,7 +318,7 @@ export const useMeshStore = defineStore('mesh', () => { async function fetchStatus() { try { loading.value = true - const res = await rpcClient.call({ method: 'mesh.status' }) + const res = await rpcClient.call({ 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({ method: 'mesh.deadman-status' }) + deadmanStatus.value = await rpcClient.call({ 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 diff --git a/neode-ui/src/stores/transport.ts b/neode-ui/src/stores/transport.ts index 4141317d..1137cc1e 100644 --- a/neode-ui/src/stores/transport.ts +++ b/neode-ui/src/stores/transport.ts @@ -45,7 +45,7 @@ export const useTransportStore = defineStore('transport', () => { try { loading.value = true error.value = null - const res = await rpcClient.call({ method: 'transport.status' }) + const res = await rpcClient.call({ 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) { diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 39ebe37d..5ff632df 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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 { + 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) {