From f177a505b4e841e2337d3508b51b13b1e23d28e1 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 30 Jul 2026 15:04:41 -0400 Subject: [PATCH] feat(02-04): main-tab side effects placed for activate/deactivate lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/useCachedResource.test.ts | 75 +++ neode-ui/src/composables/useCachedResource.ts | 17 +- neode-ui/src/views/Chat.vue | 28 +- neode-ui/src/views/Cloud.vue | 28 +- neode-ui/src/views/Home.vue | 65 ++- neode-ui/src/views/Mesh.vue | 149 ++++-- neode-ui/src/views/Server.vue | 65 ++- .../__tests__/keepAliveLifecycle.test.ts | 470 ++++++++++++++++++ neode-ui/src/views/web5/Web5.vue | 63 ++- 9 files changed, 878 insertions(+), 82 deletions(-) create mode 100644 neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts diff --git a/neode-ui/src/composables/__tests__/useCachedResource.test.ts b/neode-ui/src/composables/__tests__/useCachedResource.test.ts index bd105337..7b357000 100644 --- a/neode-ui/src/composables/__tests__/useCachedResource.test.ts +++ b/neode-ui/src/composables/__tests__/useCachedResource.test.ts @@ -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> | null = null + + const Consumer = defineComponent({ + setup() { + resource = useCachedResource({ + 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) + }) }) diff --git a/neode-ui/src/composables/useCachedResource.ts b/neode-ui/src/composables/useCachedResource.ts index c3080e41..a093e90b 100644 --- a/neode-ui/src/composables/useCachedResource.ts +++ b/neode-ui/src/composables/useCachedResource.ts @@ -97,7 +97,22 @@ export function useCachedResource(opts: CachedResourceOptions): CachedReso // loses focus). Without this a kept-alive tab would paint instantly // forever and never revalidate. Vue no-ops onActivated outside a // 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() diff --git a/neode-ui/src/views/Chat.vue b/neode-ui/src/views/Chat.vue index 0d110d0f..d59edcdd 100644 --- a/neode-ui/src/views/Chat.vue +++ b/neode-ui/src/views/Chat.vue @@ -58,7 +58,7 @@