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
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:
parent
a579556a4f
commit
f177a505b4
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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 = '' } })
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user