feat(02-04): main-tab side effects placed for activate/deactivate lifecycle
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
co-authored by Claude Fable 5
parent a579556a4f
commit f177a505b4
9 changed files with 878 additions and 82 deletions
+58 -7
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 = '' } })