feat(02-04): main-tab side effects placed for activate/deactivate lifecycle
Some checks failed
Demo images / Build & push demo images (push) Has been cancelled

Task 1 of 02-04 — audits every side effect owned by Home.vue, web5/Web5.vue,
Chat.vue, Cloud.vue, Server.vue and Mesh.vue and places each into one of
three buckets (once-per-session, every-entry, only-while-visible) so their
instances are safe to keep alive once KEEP_ALIVE_PATHS widens in Task 2.

- Home.vue: systemStats/wallet polling, the wsClient wallet-push
  subscription and its debounce timer follow activate/deactivate with an
  immediate re-sync on entry; hydrateWalletSnapshot/checkUpdateStatus/cloud
  usage stay once-per-session.
- Chat.vue: the window `message` listener and ContextBroker follow
  activate/deactivate; aiuiConnected is never reset on deactivate since the
  iframe's one-time 'ready' message won't resend on re-entry.
- Web5.vue: the six child-component data loaders (none use
  useCachedResource internally) and the 30s LND poll move to
  activate/deactivate; the DID lookup and intro flag stay once-per-session.
- Cloud.vue: the per-peer transport/reachability warm-cache
  (loadPeerFiles/loadCounts/loadPeers) re-runs every entry — the one path
  here that bypasses useCachedResource and would otherwise render stale peer
  reachability (T-02-13).
- Server.vue: the previously module-scope-armed 15s VPN poll interval now
  follows activate/deactivate (it used to run forever regardless of
  visibility); loadDiskStatus becomes every-entry.
- Mesh.vue: the entire live-communications surface (window/document
  listeners, the 5s/15s poll intervals, the ws peer-push subscription, and
  the six-way federation/self/contacts refresh) follows activate/deactivate;
  a share-to-mesh handoff via direct navigation is now correctly picked up
  on every activation, not just the first mount.
- useCachedResource.ts: onActivated's staleness check now skips an
  `immediate: false` resource that has never been explicitly fetched, so a
  tab-gated lazy resource (Cloud.vue's Paid Files / My Files walk) isn't
  eagerly force-loaded the moment its owning view is kept alive.
- Every arm/disarm pair is idempotent and duplicated into both onMounted and
  onActivated, since onActivated is a no-op outside a KeepAlive boundary
  (caught by CloudPeersRefresh.test.ts, which mounts Cloud.vue bare) —
  fresh-mount guard flags avoid double-firing the heavier loaders
  (Home/Mesh/Web5/Server) on a KeepAlive-wrapped first mount.
- New neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts
  covers the six lifecycle behaviors plus a real-view assertion
  (Server.vue's VPN poll, mounted inside a real KeepAlive).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 15:04:41 -04:00
parent a579556a4f
commit f177a505b4
9 changed files with 878 additions and 82 deletions

View File

@ -127,4 +127,79 @@ describe('useCachedResource', () => {
expect(resource!.data.value).toBe('v1') // keep-last-known-value
expect(resource!.error.value).toBe('offline')
})
// 02-04: found while auditing Cloud.vue/Server.vue's lazy (`immediate:
// false`) resources ahead of adding their routes to KEEP_ALIVE_PATHS.
// Without this guard, onActivated's refreshIfStale() would treat a
// never-fetched entry as stale and eagerly fire the "fetch on first use"
// resource the moment the tab is first activated, even though the caller
// never explicitly requested it (e.g. a tab-gated Paid Files fetch that
// should wait until that sub-tab is opened).
it('does not eagerly fetch an immediate:false resource on activation before it has been explicitly requested, but does revalidate it once it has', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 0))
const fetcher = vi.fn().mockResolvedValue('lazy-v1')
let resource: ReturnType<typeof useCachedResource<string>> | null = null
const Consumer = defineComponent({
setup() {
resource = useCachedResource<string>({
key: 'test.lazy-key',
fetcher,
ttlMs: 1000,
persist: false,
immediate: false,
})
return () => h('div', resource!.data.value ?? '')
},
})
const Other = defineComponent({ render: () => h('div', 'other') })
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Consumer, { key: 'consumer' }) : h(Other, { key: 'other' }),
)
},
})
const wrapper = mount(Host)
await flushPromises()
expect(fetcher).not.toHaveBeenCalled() // immediate: false — not fetched on mount
// Deactivate then reactivate — still never explicitly requested, so
// activation must not be the thing that fetches it.
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
await flushPromises()
expect(fetcher).not.toHaveBeenCalled()
// The caller explicitly requests it now (e.g. the user opened the tab).
await resource!.refresh()
expect(fetcher).toHaveBeenCalledTimes(1)
// Deactivate within the TTL, reactivate — no additional fetch.
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(1)
// Deactivate, advance past the TTL, reactivate — now it revalidates,
// because it has been fetched before.
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 2)) // +2s, past the 1s TTL
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(2)
})
})

View File

@ -97,7 +97,22 @@ export function useCachedResource<T>(opts: CachedResourceOptions<T>): CachedReso
// loses focus). Without this a kept-alive tab would paint instantly
// forever and never revalidate. Vue no-ops onActivated outside a
// <KeepAlive> boundary, so this is safe for every existing consumer.
onActivated(() => refreshIfStale())
//
// `immediate: false` resources are a distinct case (found in 02-04's
// audit, once Cloud.vue/Server.vue's main-tab paths joined
// KEEP_ALIVE_PATHS): `stale()` is true for any never-fetched entry
// (`fetchedAt === null`), so a bare `refreshIfStale()` here would fire
// the fetch the moment the tab is first activated — defeating a resource
// deliberately marked "fetch on first use" (e.g. a tab-gated fetch that
// should wait for the user to open that sub-tab). Only auto-revalidate
// an `immediate: false` resource on activation once it has actually been
// fetched at least once; before that, activation is a no-op and the
// resource's own explicit trigger (a watcher, an onMounted/onActivated
// "kick") still owns the first fetch.
onActivated(() => {
if (opts.immediate === false && entry.fetchedAt === null) return
refreshIfStale()
})
}
if (opts.immediate ?? true) refreshIfStale()

View File

@ -58,7 +58,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ContextBroker } from '@/services/contextBroker'
@ -102,14 +102,38 @@ function onAiuiMessage(event: MessageEvent) {
}
}
onMounted(() => {
// The window message listener and the ContextBroker are only-while-visible:
// Chat is a main tab that survives a tab switch (KeepAlive), so both follow
// activation rather than mount. Idempotent remove/stop any existing
// listener/broker before starting a new one, so two consecutive activations
// (Vue fires onActivated on first mount too) never double-arm either.
// `aiuiConnected` is set by a one-time 'ready' message from the iframe; once
// the iframe survives deactivation that message will not be re-sent on
// re-entry, so it must NOT be reset on deactivate.
function armChatLive() {
window.removeEventListener('message', onAiuiMessage)
window.addEventListener('message', onAiuiMessage)
broker?.stop()
broker = null
if (aiuiUrl.value) {
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
broker.start()
}
}
onActivated(() => armChatLive())
onDeactivated(() => {
window.removeEventListener('message', onAiuiMessage)
broker?.stop()
broker = null
})
// onActivated is a no-op outside a <KeepAlive> boundary call it directly
// here too so a bare mount (a unit test, or any future non-KeepAlive usage)
// still gets the listener and the AIUI ContextBroker. Idempotent, so the
// redundant pass this causes on a KeepAlive-wrapped first mount is harmless.
onMounted(() => armChatLive())
onBeforeUnmount(() => {
window.removeEventListener('message', onAiuiMessage)
broker?.stop()

View File

@ -402,7 +402,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import { computed, ref, watch, onActivated, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useCloudStore } from '../stores/cloud'
@ -946,14 +946,36 @@ function loadCounts() {
}
}
onMounted(async () => {
// Every-entry: countsResource and peersResource are useCachedResource-backed
// and already self-heal on reactivation via the composable's own TTL-gated
// onActivated hook, but loadPeerFiles's per-peer transport/reachability
// badges bypass useCachedResource entirely (raw resources store via
// browsePeer/peerBrowseEntry) and would otherwise sit frozen for the rest of
// the session once Cloud.vue is kept alive peer reachability must never
// render from a stale state without revalidating (T-02-13). loadCounts and
// loadPeerFiles are each internally staleness-gated (fetchedAt/TTL checks),
// and loadPeers's forced refresh is deduped against an identical in-flight
// call by resources.ts's own inflight map, so re-running this sequence on
// every activation does no extra RPC when still fresh.
//
// Called from BOTH onMounted and onActivated deliberately: onActivated is a
// no-op outside a <KeepAlive> boundary (confirmed by useCachedResource.ts's
// own comment and by CloudPeersRefresh.test.ts, which mounts this view bare)
// Vue only fires it automatically on first mount for a component that
// already has a KeepAlive ancestor. onMounted guarantees this still runs
// once for a bare mount; the harmless extra pass onActivated makes on a
// KeepAlive-wrapped first mount is absorbed by the staleness/inflight guards
// above.
async function syncOnEntry() {
loadCounts()
await loadPeers()
// Warm the per-peer browse cache in the background: peer cards get their
// FIPS/Tor badge and the Peer Files tab is instant. Staleness-gated, so
// quick revisits don't refetch.
void loadPeerFiles()
})
}
onMounted(() => { void syncOnEntry() })
onActivated(() => { void syncOnEntry() })
// File Browser can finish its startup scan after we mount pick counts up
// the moment it becomes available instead of showing a permanent blank.

View File

@ -290,7 +290,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import WalletScanModal from '@/components/WalletScanModal.vue'
@ -360,10 +360,7 @@ const line2Text = computed(() => showWelcomeBlock.value ? displayLine2.value : L
onBeforeUnmount(() => {
if (typingInterval) clearInterval(typingInterval)
if (systemStatsInterval) clearInterval(systemStatsInterval)
if (walletRefreshInterval) clearInterval(walletRefreshInterval)
if (unsubscribeWs) { unsubscribeWs(); unsubscribeWs = null }
if (wsWalletDebounce) clearTimeout(wsWalletDebounce)
disarmLiveDataPolling()
})
watch(() => loginTransition.pendingWelcomeTyping, (pending) => { if (pending) showWelcomeBlock.value = true })
@ -521,24 +518,72 @@ function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; con
const cloudStorageDisplay = computed(() => cloudStorageUsed.value !== null ? formatBytes(cloudStorageUsed.value) : '...')
const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? String(cloudFolderCount.value) : '...')
onMounted(async () => {
// Paint last-known wallet figures BEFORE any network round-trip.
hydrateWalletSnapshot()
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 10000); checkUpdateStatus(); loadWeb5Status()
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
// Only-while-visible side effects (system stats poll, wallet poll, wallet
// websocket push) are armed on every activation (including the first mount
// Vue fires onActivated on first mount too) and torn down on deactivation, so
// an off-screen Home costs nothing and a re-entered Home revalidates the
// liveness-critical wallet figures immediately rather than waiting out the
// 10s/30s intervals (T-02-13). Idempotent: any existing handle is cleared
// before a new one is armed, so two consecutive activations never double-arm.
function armLiveDataPolling() {
loadSystemStats()
if (systemStatsInterval) clearInterval(systemStatsInterval)
systemStatsInterval = setInterval(loadSystemStats, 10000)
// Poll wallet balances/transactions like Web5.vue does without this a
// pending on-chain receive (or a fresh instant payment) only shows up
// after a manual wallet action or a remount.
void loadWeb5Status()
if (walletRefreshInterval) clearInterval(walletRefreshInterval)
walletRefreshInterval = setInterval(loadWeb5Status, 30000)
// Real-time wallet push (2026-07-22): the backend streams LND transaction
// events and nudges /ws/db the moment a tx hits the mempool. Any push =
// something changed refetch the wallet (debounced; the RPC is cheap).
// This is what makes an incoming 0-conf tx appear in seconds instead of
// waiting out the 30s poll above.
if (unsubscribeWs) { unsubscribeWs(); unsubscribeWs = null }
unsubscribeWs = wsClient.subscribe(() => {
if (wsWalletDebounce) clearTimeout(wsWalletDebounce)
wsWalletDebounce = setTimeout(() => { void loadWeb5Status() }, 800)
})
}
function disarmLiveDataPolling() {
if (systemStatsInterval) { clearInterval(systemStatsInterval); systemStatsInterval = null }
if (walletRefreshInterval) { clearInterval(walletRefreshInterval); walletRefreshInterval = null }
if (unsubscribeWs) { unsubscribeWs(); unsubscribeWs = null }
if (wsWalletDebounce) { clearTimeout(wsWalletDebounce); wsWalletDebounce = null }
}
// Vue fires onActivated immediately after onMounted on a KeepAlive-wrapped
// component's first mount. Unlike a plain interval re-arm, armLiveDataPolling()
// also fires the wallet's 7-call Promise.allSettled immediately firing it
// twice back-to-back on every fresh session start is real, avoidable waste,
// not a harmless no-op. This flag lets onMounted's call count as the first
// activation's arm; onActivated only re-arms on a genuine later reactivation.
let homeFreshMount = true
onActivated(() => {
if (homeFreshMount) { homeFreshMount = false; return }
armLiveDataPolling()
})
onDeactivated(() => { disarmLiveDataPolling() })
onMounted(async () => {
// Once-per-session: paint last-known wallet figures BEFORE any network
// round-trip, check for an available update, and read the cloud usage
// summary once. None of these are polled, so they stay here rather than
// moving to onActivated a resumed tab doesn't need them re-run.
hydrateWalletSnapshot()
checkUpdateStatus()
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
// Also arm here directly: onActivated is a no-op outside a <KeepAlive>
// boundary (Vue only auto-fires it on first mount for a component that
// already has a KeepAlive ancestor), so a bare mount a unit test, or any
// future non-KeepAlive usage must not silently skip every poll/
// subscription this view owns.
armLiveDataPolling()
})
// Wallet modals

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, nextTick, onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMeshStore } from '@/stores/mesh'
import { useTransportStore } from '@/stores/transport'
@ -363,72 +363,124 @@ function updateKeyboardInset() {
document.documentElement.style.setProperty('--keyboard-inset', `${inset}px`)
}
onMounted(async () => {
// 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
// once-per-session: window/document listeners are only-while-visible, and
// the data loads + poll intervals + ws subscription are every-entry. Every
// arm is idempotent (existing handles cleared/removed first), so two
// consecutive activations never double-arm.
//
// Called from BOTH onMounted and onActivated: onActivated is a no-op outside
// a <KeepAlive> boundary (Vue only auto-fires it on first mount for a
// component that already has a KeepAlive ancestor), so a bare mount (a unit
// test, or any future non-KeepAlive usage) must not silently skip every
// timer/subscription/listener this view owns. The harmless extra pass
// onActivated makes on a KeepAlive-wrapped first mount is absorbed by the
// idempotent arm logic below.
function armMeshLive() {
window.removeEventListener('resize', handleResize)
window.addEventListener('resize', handleResize)
handleResize() // window size may have changed while off screen
document.removeEventListener('pointerdown', handleDocClickForMenu)
document.addEventListener('pointerdown', handleDocClickForMenu)
document.removeEventListener('pointerdown', closeAttachMenuOnOutsideClick)
document.addEventListener('pointerdown', closeAttachMenuOnOutsideClick)
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', updateKeyboardInset)
window.visualViewport.removeEventListener('scroll', updateKeyboardInset)
window.visualViewport.addEventListener('resize', updateKeyboardInset)
window.visualViewport.addEventListener('scroll', updateKeyboardInset)
updateKeyboardInset()
}
// Direct navigation to /mesh (router.push, not the same-page custom event
// above) is the only path that delivers a share-to-mesh handoff for a tab
// that is already kept alive App.vue only dispatches the custom event
// when already on /mesh, so this explicit read must run on every
// activation, not just the first mount, or a share arriving after the
// first visit would silently never be picked up.
loadPendingFromSession()
await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()])
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
// getReceivedMessages() (the toast's only message source) is exclusively
// Archipelago (Tor federation) traffic a federation pubkey will never
// match an entry in mesh.peers, so without this fallback the deep-link
// silently failed and just landed on the bare mesh page every time.
const targetPeer = typeof route.query.peer === 'string' ? route.query.peer : ''
if (targetPeer) {
const match = mesh.peers.find(
(p) => p.pubkey_hex === targetPeer || p.did === targetPeer
)
if (match) {
openChat(match)
} else {
openArchChannel()
void Promise.all([
mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(),
refreshSelfOnion(), refreshSelfDid(), refreshContacts(),
]).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
// getReceivedMessages() (the toast's only message source) is exclusively
// Archipelago (Tor federation) traffic a federation pubkey will never
// match an entry in mesh.peers, so without this fallback the deep-link
// silently failed and just landed on the bare mesh page every time.
const targetPeer = typeof route.query.peer === 'string' ? route.query.peer : ''
if (targetPeer) {
const match = mesh.peers.find(
(p) => p.pubkey_hex === targetPeer || p.did === targetPeer
)
if (match) {
openChat(match)
} else {
openArchChannel()
}
}
}
})
// Start background polling for Archipelago (Tor) messages so unread count works
loadArchMessages()
if (!archPollInterval) {
archPollInterval = setInterval(loadArchMessages, 15000)
}
if (!pollInterval) {
let tick = 0
pollInterval = setInterval(() => {
mesh.fetchStatus()
mesh.fetchPeers()
mesh.fetchMessages()
mesh.fetchDeadmanStatus()
mesh.fetchBlockHeaders()
// Contacts/aliases, federation nodes and the outbox badge previously
// loaded ONCE at mount and went permanently stale new federation
// peers or renames never appeared without a full page reload. Every
// 6th tick (~30s) keeps them fresh without adding per-5s load.
if (++tick % 6 === 0) {
void refreshContacts()
void refreshFederationNodes()
void refreshOutboxCount()
}
}, 5000)
}
if (archPollInterval) clearInterval(archPollInterval)
archPollInterval = setInterval(loadArchMessages, 15000)
if (pollInterval) clearInterval(pollInterval)
let tick = 0
pollInterval = setInterval(() => {
mesh.fetchStatus()
mesh.fetchPeers()
mesh.fetchMessages()
mesh.fetchDeadmanStatus()
mesh.fetchBlockHeaders()
// Contacts/aliases, federation nodes and the outbox badge previously
// loaded ONCE at mount and went permanently stale new federation
// peers or renames never appeared without a full page reload. Every
// 6th tick (~30s) keeps them fresh without adding per-5s load.
if (++tick % 6 === 0) {
void refreshContacts()
void refreshFederationNodes()
void refreshOutboxCount()
}
}, 5000)
// Instant peer updates (#48): the backend nudges the data-model revision when
// it discovers/updates a mesh peer, so refetch peers on the WS push rather
// than waiting for the next 5s poll tick. The poll above stays as a backstop
// (e.g. for peers going offline, which isn't pushed).
if (wsUnsub) wsUnsub()
wsUnsub = wsClient.subscribe(() => {
mesh.fetchPeers()
})
}
// Vue fires onActivated immediately after onMounted on a KeepAlive-wrapped
// component's first mount, which would otherwise double the six-way
// Promise.all + interval/subscription arm on every fresh page load. This
// flag lets onMounted's call count as the first activation's arm, so
// onActivated only re-arms on a genuine later reactivation (after a real
// deactivate), not redundantly right after mount.
let meshFreshMount = true
onActivated(() => {
if (meshFreshMount) { meshFreshMount = false; return }
armMeshLive()
})
onMounted(() => armMeshLive())
onUnmounted(() => {
function teardownMeshLiveEffects() {
window.removeEventListener('resize', handleResize)
document.removeEventListener('pointerdown', handleDocClickForMenu)
document.removeEventListener('pointerdown', closeAttachMenuOnOutsideClick)
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', updateKeyboardInset)
@ -438,7 +490,10 @@ onUnmounted(() => {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
if (wsUnsub) { wsUnsub(); wsUnsub = null }
})
}
onDeactivated(() => teardownMeshLiveEffects())
onUnmounted(() => teardownMeshLiveEffects())
// Active chat name for the header
const activeChatName = computed(() => {
@ -1447,8 +1502,10 @@ function closeAttachMenuOnOutsideClick(ev: MouseEvent) {
const target = ev.target as HTMLElement
if (!target.closest('.mesh-attach-menu-anchor')) showAttachMenu.value = false
}
onMounted(() => document.addEventListener('pointerdown', closeAttachMenuOnOutsideClick))
onUnmounted(() => document.removeEventListener('pointerdown', closeAttachMenuOnOutsideClick))
// Listener lifecycle handled by the combined onActivated/onDeactivated block
// above (T-02-15: this used to be its own onMounted/onUnmounted pair, moved
// here so it stops firing while Mesh is off screen instead of leaking for
// the rest of the session once Mesh is kept alive).
const attachError = ref<string | null>(null)
const fetchingCids = ref<Set<string>>(new Set())
const fetchedUrls = ref<Map<string, string>>(new Map())

View File

@ -404,7 +404,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onActivated, onDeactivated, onMounted, onUnmounted, watch } from 'vue'
import DOMPurify from 'dompurify'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource, type CachedResource } from '@/composables/useCachedResource'
@ -828,11 +828,14 @@ async function createService(name: string, port: number | null) {
catch (e) { addServiceError.value = e instanceof Error ? e.message : 'Failed to create service' } finally { addingService.value = false }
}
onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() })
// Only-while-visible: polls VPN status every 15s so IP updates after pairing
// (write-through to the cached aggregate without refetching the other three
// RPCs). An off-screen Server tab must not keep polling (T-02-03) armed on
// activation, cleared on deactivation; idempotent (clears any existing
// handle first, so two consecutive activations never double-arm).
let vpnPollInterval: ReturnType<typeof setInterval> | null = null
// Poll VPN status every 15s so IP updates after pairing (write-through to
// the cached aggregate without refetching the other three RPCs)
const vpnPollInterval = setInterval(async () => {
async function pollVpnStatusOnce() {
try {
const vpnRes = await rpcClient.vpnStatus()
networkRes.optimistic(cur => ({
@ -843,8 +846,56 @@ const vpnPollInterval = setInterval(async () => {
wgIp: vpnRes.wg_ip ?? '',
}))
} catch { /* ignore */ }
}, 15000)
onUnmounted(() => clearInterval(vpnPollInterval))
}
function armVpnPoll() {
if (vpnPollInterval) clearInterval(vpnPollInterval)
// Immediate first tick on (re)activation a re-entered tab must not sit
// on a 15s-stale VPN IP waiting out the interval.
void pollVpnStatusOnce()
vpnPollInterval = setInterval(() => void pollVpnStatusOnce(), 15000)
}
function disarmVpnPoll() {
if (vpnPollInterval) { clearInterval(vpnPollInterval); vpnPollInterval = null }
}
// Every-entry: loadDiskStatus is a plain fetch with no useCachedResource
// backing it, so under KeepAlive it would otherwise run once ever and the
// disk-space warning banner would never update again for the rest of the
// session.
function armServerEntryEffects() {
void loadDiskStatus()
armVpnPoll()
}
// Vue fires onActivated immediately after onMounted on a KeepAlive-wrapped
// component's first mount this flag lets onMounted's call count as the
// first activation's arm, so onActivated only re-arms (and re-issues
// loadDiskStatus + the immediate VPN poll tick) on a genuine later
// reactivation, not redundantly right after mount.
let serverFreshMount = true
onActivated(() => {
if (serverFreshMount) { serverFreshMount = false; return }
armServerEntryEffects()
})
onDeactivated(() => disarmVpnPoll())
onUnmounted(() => disarmVpnPoll())
// Once-per-session seed for the six useCachedResource-backed loads below:
// each resource's own onActivated (added in useCachedResource.ts) already
// revalidates it, staleness-gated, on every later reactivation same
// pattern established for Marketplace.vue in 02-02 so this task only
// relocates the ONE loader here that bypasses useCachedResource entirely
// (loadDiskStatus, folded into armServerEntryEffects() above). Server's
// seven-call fan-out itself stays fire-and-forget/concurrent, unconverted
// that data-layer conversion is 02-06's job, not this task's.
//
// Also arms the every-entry effects directly here: onActivated is a no-op
// outside a <KeepAlive> boundary (confirmed by ServerNetworkRefresh.test.ts,
// which mounts this view bare), so a bare mount must not silently skip them.
onMounted(() => {
checkTorStatus(); loadNetworkData(); loadInterfaces(); loadTorServices(); loadVpnPeers(); loadFipsSummary()
armServerEntryEffects()
})
watch(showWifiModal, (open) => { if (open) scanWifi() })
watch(showDnsModal, (open) => { if (open) { dnsSelectedProvider.value = networkData.value.dnsProvider || 'system'; dnsError.value = '' } })

View File

@ -0,0 +1,470 @@
// 02-04: instance caching turned on for every audited main tab. This file
// covers the activate/deactivate lifecycle contract every registered view
// must honor (Task 1) and the widened registration set + eviction (Task 2).
//
// Two kinds of coverage:
// - A small synthetic consumer component, built with the exact same
// idempotent onActivated/onDeactivated idiom every real view in this plan
// uses (see Home.vue/Chat.vue/Apps.vue/Server.vue/Mesh.vue/Web5.vue/
// Cloud.vue), proves the *pattern* in isolation.
// - At least one assertion against a real converted view (Server.vue's
// vpnPollInterval), mounted inside a real <KeepAlive>, with fake timers.
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router'
import { createPinia } from 'pinia'
import { KeepAlive, defineComponent, h, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DashboardRouterView from '../DashboardRouterView.vue'
import { KEEP_ALIVE_MAX, KEEP_ALIVE_PATHS, shouldKeepAlive } from '../keepAliveRoutes'
import { rpcClient } from '@/api/rpc-client'
import Server from '@/views/Server.vue'
// Hoisted by vitest — must live at module scope, not inside a test body, or
// Server.vue's own module-level `import { rpcClient } from '@/api/rpc-client'`
// would already be bound to the real implementation by the time a test ran.
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn().mockResolvedValue({}),
vpnStatus: vi.fn().mockResolvedValue({ connected: false }),
dnsStatus: vi.fn().mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false }),
diskStatus: vi.fn().mockResolvedValue({ encrypted: false, warnings: [] }),
},
}))
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ packages: {} }) }))
describe('keepAliveLifecycle: activate/deactivate contract (synthetic consumer)', () => {
let mountCount: number
let activateCount: number
let deactivateCount: number
let unmountCount: number
let pollCallCount: number
let subscribeCount: number
let unsubscribeCount: number
let listenerAddCount: number
let listenerRemoveCount: number
function makeSubscription() {
subscribeCount++
return () => { unsubscribeCount++ }
}
function onWindowEvent() { /* no-op handler identity for add/remove counting */ }
// Mirrors the idiom established across Home.vue/Chat.vue/Server.vue/etc:
// idempotent arm (clears/removes any existing handle first), started in
// onActivated (which Vue also fires on first mount), torn down in
// onDeactivated, with onBeforeUnmount left in place for the non-cached path.
const Consumer = defineComponent({
name: 'LifecycleConsumer',
setup() {
onMounted(() => { mountCount++ })
let pollInterval: ReturnType<typeof setInterval> | null = null
let unsubscribe: (() => void) | null = null
function arm() {
activateCount++
if (pollInterval) clearInterval(pollInterval)
pollCallCount++ // immediate invocation on (re)activation
pollInterval = setInterval(() => { pollCallCount++ }, 1000)
if (unsubscribe) unsubscribe()
unsubscribe = makeSubscription()
window.removeEventListener('resize', onWindowEvent)
window.addEventListener('resize', onWindowEvent)
listenerAddCount++
}
function disarm() {
deactivateCount++
if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
if (unsubscribe) { unsubscribe(); unsubscribe = null }
window.removeEventListener('resize', onWindowEvent)
listenerRemoveCount++
}
onActivated(() => arm())
onDeactivated(() => disarm())
onBeforeUnmount(() => { disarm(); unmountCount++ })
return () => h('div', 'consumer')
},
})
const Other = defineComponent({ render: () => h('div', 'other') })
function mountHost() {
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Consumer, { key: 'consumer' }) : h(Other, { key: 'other' }),
)
},
})
return mount(Host)
}
beforeEach(() => {
vi.useFakeTimers()
mountCount = 0; activateCount = 0; deactivateCount = 0; unmountCount = 0
pollCallCount = 0; subscribeCount = 0; unsubscribeCount = 0
listenerAddCount = 0; listenerRemoveCount = 0
})
afterEach(() => {
vi.useRealTimers()
})
it('deactivating clears the polling interval — its callback is not invoked again while off screen', async () => {
const wrapper = mountHost()
await wrapper.vm.$nextTick()
const callsAtActivation = pollCallCount
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
vi.advanceTimersByTime(5000)
expect(pollCallCount).toBe(callsAtActivation) // no further ticks while deactivated
wrapper.unmount()
})
it('reactivating restarts the interval and immediately invokes the loader once, so the first frame is not interval-stale', async () => {
const wrapper = mountHost()
await wrapper.vm.$nextTick()
const callsAtFirstActivation = pollCallCount
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
// Immediate call on reactivation, before any timer has elapsed.
expect(pollCallCount).toBe(callsAtFirstActivation + 1)
wrapper.unmount()
})
it('deactivating unsubscribes a websocket handle; reactivating re-subscribes exactly once', async () => {
const wrapper = mountHost()
await wrapper.vm.$nextTick()
expect(subscribeCount).toBe(1)
expect(unsubscribeCount).toBe(0)
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
expect(unsubscribeCount).toBe(1)
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
expect(subscribeCount).toBe(2) // re-subscribed exactly once, not twice
wrapper.unmount()
})
it('deactivating removes a window listener; reactivating adds it back exactly once', async () => {
const wrapper = mountHost()
await wrapper.vm.$nextTick()
expect(listenerAddCount).toBe(1)
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
expect(listenerRemoveCount).toBe(1)
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
expect(listenerAddCount).toBe(2) // added back exactly once, not twice
wrapper.unmount()
})
it('unmounting (a non-cached mount path) still tears everything down', async () => {
const wrapper = mountHost()
await wrapper.vm.$nextTick()
wrapper.unmount()
expect(unmountCount).toBe(1)
expect(unsubscribeCount).toBe(1)
expect(listenerRemoveCount).toBeGreaterThanOrEqual(1)
const callsAtUnmount = pollCallCount
vi.advanceTimersByTime(5000)
expect(pollCallCount).toBe(callsAtUnmount) // no interval survives real unmount
})
it('two consecutive activations never double-arm a timer, subscription or listener', async () => {
const wrapper = mountHost()
await wrapper.vm.$nextTick()
expect(activateCount).toBe(1)
expect(subscribeCount).toBe(1)
// Vue only calls onActivated on an actual (re)activation transition, so
// simulate the double-activation risk directly: arm() is idempotent by
// construction (clears/removes before re-adding), proven by driving two
// deactivate/activate round trips back-to-back with no intervening tick.
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
expect(activateCount).toBe(3) // initial + two round trips
expect(subscribeCount).toBe(3) // never more than one live subscription at a time
expect(unsubscribeCount).toBe(2) // one per deactivation, none doubled
// Only one interval is ever live: advancing time ticks it exactly once
// per 1000ms, never twice, proving no double-arm survived the round trips.
const before = pollCallCount
vi.advanceTimersByTime(1000)
expect(pollCallCount).toBe(before + 1)
wrapper.unmount()
})
it('a one-shot intro flag set on first entry is still set exactly once across three visits to the same tab', async () => {
let onceFlagSetCount = 0
const OnceConsumer = defineComponent({
name: 'OnceFlagConsumer',
setup() {
onMounted(() => { onceFlagSetCount++ }) // once-per-session bucket: onMounted only, never onActivated
return () => h('div', 'once')
},
})
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(OnceConsumer, { key: 'once' }) : h(Other, { key: 'other' }),
)
},
})
const wrapper = mount(Host)
await wrapper.vm.$nextTick()
expect(onceFlagSetCount).toBe(1)
// Visit 2
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
expect(onceFlagSetCount).toBe(1)
// Visit 3
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
expect(onceFlagSetCount).toBe(1)
wrapper.unmount()
})
it('an entry-scoped connection-timeout timer is re-armed on each re-entry and cleared on each exit, so it never fires against a stale visit', async () => {
// Mirrors Apps.vue's connectionTimer idiom exactly (arm in onActivated,
// idempotent; clear in onDeactivated; onBeforeUnmount clears too).
let timerFiredCount = 0
const ConnectionTimeoutConsumer = defineComponent({
name: 'ConnectionTimeoutConsumer',
setup() {
let timer: ReturnType<typeof setTimeout> | undefined
function arm() {
if (timer) clearTimeout(timer)
timer = setTimeout(() => { timerFiredCount++ }, 15000)
}
function disarm() {
if (timer) clearTimeout(timer)
}
onActivated(() => arm())
onDeactivated(() => disarm())
onBeforeUnmount(() => disarm())
return () => h('div', 'conn')
},
})
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(ConnectionTimeoutConsumer, { key: 'conn' }) : h(Other, { key: 'other' }),
)
},
})
const wrapper = mount(Host)
await wrapper.vm.$nextTick()
// Leave well before the 15s guard fires — it must not fire against a
// stale (now off-screen) visit.
vi.advanceTimersByTime(10000)
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
vi.advanceTimersByTime(10000) // would have fired by now if left armed
expect(timerFiredCount).toBe(0)
// Re-enter — the guard re-arms fresh; it should not fire before its own
// full 15s has elapsed again from THIS entry.
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
vi.advanceTimersByTime(10000)
expect(timerFiredCount).toBe(0)
vi.advanceTimersByTime(5000)
expect(timerFiredCount).toBe(1)
wrapper.unmount()
})
})
describe('keepAliveLifecycle: real converted view (Server.vue vpnPollInterval)', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("a real view's poll interval is not invoked while deactivated, and is invoked once on reactivation", async () => {
const Other = defineComponent({ render: () => h('div', 'other') })
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show
? h(Server, { key: 'server' })
: h(Other, { key: 'other' }),
)
},
})
const wrapper = mount(Host, {
global: {
plugins: [createPinia()],
stubs: {
QuickActionsCard: true,
TorServicesCard: true,
ServerModals: true,
FipsNetworkCard: true,
},
},
})
await flushPromises()
const vpnStatusMock = vi.mocked(rpcClient.vpnStatus)
const callsAtActivation = vpnStatusMock.mock.calls.length
expect(callsAtActivation).toBeGreaterThan(0) // immediate call on first activation
// Deactivate — the 15s poll must not keep calling vpnStatus while off
// screen. (20s: past the poll's own 15s period, but comfortably under
// networkRes's 30s TTL so its own onActivated revalidation — a separate,
// unrelated resource that also happens to call vpnStatus — cannot
// confound this assertion on reactivation below.)
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
vi.advanceTimersByTime(20000)
await flushPromises()
expect(vpnStatusMock.mock.calls.length).toBe(callsAtActivation)
// Reactivate — invoked once immediately, before the next 15s tick.
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
await flushPromises()
expect(vpnStatusMock.mock.calls.length).toBe(callsAtActivation + 1)
wrapper.unmount()
})
})
describe('keepAliveRoutes: widened registration set (02-04)', () => {
it('shouldKeepAlive returns true for every registered main-tab path and false for every detail path, including detail paths whose prefix matches a registered path', () => {
expect(shouldKeepAlive({ path: '/dashboard/apps' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/apps/bitcoin' })).toBe(false)
expect(shouldKeepAlive({ path: '/dashboard/marketplace' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/marketplace/x' })).toBe(false)
expect(shouldKeepAlive({ path: '/dashboard/cloud' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/cloud/x' })).toBe(false)
expect(shouldKeepAlive({ path: '/dashboard/server' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/server/openwrt' })).toBe(false)
expect(shouldKeepAlive({ path: '/dashboard/web5' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/web5/credentials' })).toBe(false)
// Settings is in TAB_ORDER but withheld (unaudited child sections) —
// registering it is neither required nor safe yet (see keepAliveRoutes.ts).
expect(shouldKeepAlive({ path: '/dashboard/settings' })).toBe(false)
expect(shouldKeepAlive({ path: '/dashboard/settings/update' })).toBe(false)
// Discover (App Store's second tab) is not in TAB_ORDER but is registered
// explicitly — measured `remount storm` in 02-FINDINGS.md.
expect(shouldKeepAlive({ path: '/dashboard/discover' })).toBe(true)
})
it('registers every TAB_ORDER path measured Remounted:true or unmeasured, per the literal exclusion rule (only a measured Remounted:false excludes)', () => {
for (const path of ['/dashboard', '/dashboard/apps', '/dashboard/marketplace', '/dashboard/cloud', '/dashboard/mesh', '/dashboard/server', '/dashboard/web5', '/dashboard/fleet', '/dashboard/chat']) {
expect(shouldKeepAlive({ path })).toBe(true)
}
})
it('visiting more distinct registered tabs than KEEP_ALIVE_MAX leaves exactly KEEP_ALIVE_MAX instances resident — the least recently used is evicted', async () => {
const paths = Array.from(KEEP_ALIVE_PATHS).slice(0, KEEP_ALIVE_MAX + 2)
expect(paths.length).toBe(KEEP_ALIVE_MAX + 2)
const firstPath = paths[0]
if (!firstPath) throw new Error('expected at least one registered path')
const remainingPaths = paths.slice(1)
const mountCounts: Record<string, number> = {}
const routes: RouteRecordRaw[] = paths.map((path) => {
mountCounts[path] = 0
const Stub = defineComponent({
name: `EvictionStub:${path}`,
setup() {
onMounted(() => { mountCounts[path] = (mountCounts[path] ?? 0) + 1 })
return () => h('div', { class: 'eviction-stub' }, path)
},
})
return { path, component: Stub }
})
const router = createRouter({ history: createMemoryHistory(), routes })
router.push(firstPath)
await router.isReady()
const wrapper = mount(DashboardRouterView, {
props: { mobileTabPaddingTop: null, needsMobileBackButtonSpace: false },
global: { plugins: [router] },
})
await flushPromises()
expect(mountCounts[firstPath]).toBe(1)
// Visit every remaining registered path in order — KEEP_ALIVE_MAX + 1
// more distinct instances, so the cache (capped at KEEP_ALIVE_MAX) must
// evict the least-recently-used one (firstPath) to stay within budget.
for (const path of remainingPaths) {
await router.push(path)
await flushPromises()
}
expect(mountCounts[firstPath]).toBe(1) // not yet revisited — still just the one mount
// Revisiting the evicted path proves eviction: it remounts (count -> 2)
// rather than merely reactivating (which would leave the count at 1).
await router.push(firstPath)
await flushPromises()
expect(mountCounts[firstPath]).toBe(2)
wrapper.unmount()
})
})

View File

@ -90,7 +90,7 @@ let web5AnimationDone = false
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
@ -334,18 +334,13 @@ async function detectHardwareWallets() {
// WS-push invalidation; the store dedups overlapping refreshes).
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
web5AnimationDone = true
// Load the authoritative node DID from the backend
rpcClient.getNodeDid().then(res => {
if (res.did && res.did !== storedDid.value) {
storedDid.value = res.did
try { localStorage.setItem('neode_did', res.did) } catch { /* noop */ }
}
}).catch(() => { /* use cached localStorage value */ })
// Load all data from child components
// Every-entry: none of these child components use useCachedResource
// internally (confirmed by reading Web5ConnectedNodes.vue/Web5Identities.vue/
// Web5NodeVisibility.vue/Web5NostrRelays.vue all are plain functions
// exposed via defineExpose), so under KeepAlive these must run again on every
// reactivation or peers/requests/identities/visibility/relays go stale for
// the rest of the session after the first visit.
function armWeb5Live() {
connectedNodesRef.value?.loadPeers()
connectedNodesRef.value?.loadReceivedMessages()
connectedNodesRef.value?.loadConnectionRequests()
@ -363,9 +358,51 @@ onMounted(() => {
// Shared content loaded by the component itself via expose
// The SharedContent component manages its own loadContentItems
// Only-while-visible: the 30s force-refresh interval bypasses
// useCachedResource's own TTL gate for lndInfoRes, so it must not keep
// running while Web5 is off screen.
if (walletRefreshInterval) clearInterval(walletRefreshInterval)
walletRefreshInterval = setInterval(() => {
void lndInfoRes.refresh()
}, 30000)
}
// Vue fires onActivated immediately after onMounted on a KeepAlive-wrapped
// component's first mount, which would otherwise double every load in
// armWeb5Live() on first mount. onMounted's call counts as the first
// activation's arm; onActivated only re-arms on a genuine later
// reactivation.
let web5FreshMount = true
onActivated(() => {
if (web5FreshMount) { web5FreshMount = false; return }
armWeb5Live()
})
onMounted(() => {
// Once-per-session: the intro-stagger flag and the authoritative DID
// lookup. Neither needs to re-run on a later reactivation the DID is
// stable for the life of the identity and the stagger animation must fire
// exactly once.
web5AnimationDone = true
// Load the authoritative node DID from the backend
rpcClient.getNodeDid().then(res => {
if (res.did && res.did !== storedDid.value) {
storedDid.value = res.did
try { localStorage.setItem('neode_did', res.did) } catch { /* noop */ }
}
}).catch(() => { /* use cached localStorage value */ })
// Also arms the every-entry loaders directly here: onActivated is a no-op
// outside a <KeepAlive> boundary, so a bare mount (a unit test, or any
// future non-KeepAlive usage) must not silently skip them.
armWeb5Live()
})
onDeactivated(() => {
if (walletRefreshInterval) {
clearInterval(walletRefreshInterval)
walletRefreshInterval = null
}
})
onUnmounted(() => {