From c0291d6dfe568d448bcc49b93496579c62295b2d Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 11:58:35 +0000 Subject: [PATCH] fix(overlay): keep a page that reconnects in a background tab alive When the link dropped, `leave()` stopped the refresher. On reconnect, `onVisibility()` returned early in its hidden branch because the refresher was gone, so a page still in a background tab never reported itself hidden and never started its hidden heartbeat. The server could then expire it and drop its data. The hidden branch now checks whether the page is already marked hidden instead. A second drop during the `restored` calls (claim-page, redaction) no longer restarts the heartbeat, refresher and collectors while offline: the handler stands down when the link dropped again in between. --- .../overlay-reconnect-visibility.test.ts | 196 ++++++++++++++++++ .../src/__tests__/overlay-visibility.test.ts | 15 ++ packages/devtools/src/overlay.ts | 17 +- 3 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 packages/devtools/src/__tests__/overlay-reconnect-visibility.test.ts diff --git a/packages/devtools/src/__tests__/overlay-reconnect-visibility.test.ts b/packages/devtools/src/__tests__/overlay-reconnect-visibility.test.ts new file mode 100644 index 00000000..76233f9c --- /dev/null +++ b/packages/devtools/src/__tests__/overlay-reconnect-visibility.test.ts @@ -0,0 +1,196 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface FakeClient { + status: string; + readonly isTrusted: boolean; + calls: { name: string; args: unknown[] }[]; + set(status: string): void; +} + +const clients: FakeClient[] = []; +let nextStatus = 'connected'; +/** While set, `claim-page` waits for it, so a test can drop the link during the call. */ +let claimGate: Promise | null = null; +const configs = { pangular: { inspectors: { analog: false, router: false } } }; + +function fakeClient(status: string): FakeClient { + const listeners = new Set<(status: string) => void>(); + const client = { + status, + get isTrusted() { + return client.status === 'connected'; + }, + calls: [] as { name: string; args: unknown[] }[], + connectionMeta: { backend: 'websocket', configs }, + events: { + on: (event: string, listener: (status: string) => void) => { + if (event !== 'connection:status') return () => {}; + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + scope: () => ({ + rpc: { + call: async (name: string, ...args: unknown[]) => { + client.calls.push({ name, args }); + if (name === 'claim-page' && claimGate) await claimGate; + if (client.status !== 'connected') { + throw new Error('[devframe] Not connected to the devframe server'); + } + return undefined; + }, + register: () => {}, + }, + }), + close: () => {}, + set(next: string) { + client.status = next; + for (const listener of [...listeners]) listener(next); + }, + }; + return client; +} + +vi.mock('devframe/client', () => ({ + connectDevframe: async () => { + const client = fakeClient(nextStatus); + clients.push(client); + return client; + }, +})); + +/** Change-detection watchers (the overlay's refresher) that are started and not stopped. */ +let watching = 0; +vi.mock('../change-detection.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + watchChangeDetection: (options: Parameters[0]) => { + const scheduler = actual.watchChangeDetection(options); + watching++; + let stopped = false; + const stop = scheduler.stop; + scheduler.stop = () => { + if (!stopped) watching--; + stopped = true; + stop(); + }; + return scheduler; + }, + }; +}); + +let hidden = false; +const stops: (() => void)[] = []; + +async function start() { + vi.resetModules(); + const { initOverlay } = await import('../overlay.ts'); + const pending = initOverlay(); + await vi.advanceTimersByTimeAsync(200); + stops.push(await pending); +} + +function setHidden(value: boolean) { + hidden = value; + document.dispatchEvent(new Event('visibilitychange')); +} + +const named = (client: FakeClient, name: string) => client.calls.filter((c) => c.name === name); +const visibility = (client: FakeClient) => + named(client, 'report-page-visibility').map((call) => call.args[0]); + +describe.sequential('overlay reconnect and page visibility', () => { + beforeEach(() => { + clients.length = 0; + nextStatus = 'connected'; + claimGate = null; + watching = 0; + hidden = false; + vi.stubGlobal('BroadcastChannel', undefined); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => (hidden ? 'hidden' : 'visible'), + }); + vi.useFakeTimers({ + toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'], + }); + }); + + afterEach(() => { + stops.splice(0).forEach((stop) => stop()); + vi.useRealTimers(); + vi.unstubAllGlobals(); + sessionStorage.clear(); + }); + + it('reports a page that reconnects in a background tab as hidden and keeps it marked', async () => { + await start(); + const [first] = clients; + await vi.advanceTimersByTimeAsync(2000); + setHidden(true); + await vi.advanceTimersByTimeAsync(0); + expect(visibility(first).at(-1)).toMatchObject({ hidden: true }); + + // The server restarts while the tab is in the background. + first.set('disconnected'); + await vi.advanceTimersByTimeAsync(1000); + const live = clients.at(-1)!; + expect(live).not.toBe(first); + expect(named(live, 'claim-page').length).toBe(1); + + // The new server hears that the page is hidden, so it does not expire it. + expect(visibility(live).at(-1)).toMatchObject({ hidden: true }); + expect(watching).toBe(0); + live.calls.length = 0; + await vi.advanceTimersByTimeAsync(130_000); + const reports = visibility(live); + expect(reports.length).toBeGreaterThanOrEqual(2); + expect(reports.every((report) => (report as { hidden: boolean }).hidden)).toBe(true); + expect(named(live, 'push-component-tree')).toEqual([]); + + // Back to the tab: it reports and collects as usual. + live.calls.length = 0; + setHidden(false); + await vi.advanceTimersByTimeAsync(0); + expect(visibility(live).at(-1)).toMatchObject({ hidden: false }); + expect(watching).toBe(1); + await vi.advanceTimersByTimeAsync(10_000); + expect(named(live, 'page-heartbeat').length).toBeGreaterThan(0); + }); + + it('stays quiet when the link drops again while the page is being claimed', async () => { + await start(); + const [first] = clients; + await vi.advanceTimersByTimeAsync(2000); + expect(watching).toBe(1); + + let openGate = () => {}; + claimGate = new Promise((resolve) => (openGate = resolve)); + first.set('disconnected'); + expect(watching).toBe(0); + await vi.advanceTimersByTimeAsync(1000); + const second = clients.at(-1)!; + expect(named(second, 'claim-page').length).toBe(1); + + // The new connection drops before the claim answers, and the server stays down. + nextStatus = 'error'; + second.set('disconnected'); + openGate(); + await vi.advanceTimersByTimeAsync(0); + expect(watching).toBe(0); + await vi.advanceTimersByTimeAsync(20_000); + expect(watching).toBe(0); + + // When the server is back, the page reports as usual. + claimGate = null; + nextStatus = 'connected'; + await vi.advanceTimersByTimeAsync(60_000); + const live = clients.at(-1)!; + expect(live.status).toBe('connected'); + expect(named(live, 'claim-page').length).toBe(1); + expect(watching).toBe(1); + expect(named(live, 'push-component-tree').length).toBeGreaterThan(0); + }); +}); diff --git a/packages/devtools/src/__tests__/overlay-visibility.test.ts b/packages/devtools/src/__tests__/overlay-visibility.test.ts index 729c15fc..7297bd79 100644 --- a/packages/devtools/src/__tests__/overlay-visibility.test.ts +++ b/packages/devtools/src/__tests__/overlay-visibility.test.ts @@ -140,4 +140,19 @@ describe.sequential('overlay keepalive and background tabs', () => { await vi.advanceTimersByTimeAsync(0); expect(sent('report-page-visibility').at(-1)?.args[0]).toMatchObject({ hidden: true }); }); + + it('keeps a hidden tab marked when it comes back from the back/forward cache still hidden', async () => { + await start(); + setHidden(true); + await vi.advanceTimersByTimeAsync(0); + window.dispatchEvent(new Event('pagehide')); + await vi.advanceTimersByTimeAsync(0); + calls.length = 0; + window.dispatchEvent(new Event('pageshow')); + await vi.advanceTimersByTimeAsync(130_000); + const reports = sent('report-page-visibility').map((call) => call.args[0]); + expect(reports.length).toBeGreaterThanOrEqual(2); + expect(reports).not.toContainEqual(expect.objectContaining({ hidden: false })); + expect(sent('push-component-tree')).toEqual([]); + }); }); diff --git a/packages/devtools/src/overlay.ts b/packages/devtools/src/overlay.ts index 9bb25f57..ff069f9e 100644 --- a/packages/devtools/src/overlay.ts +++ b/packages/devtools/src/overlay.ts @@ -833,10 +833,12 @@ async function startOverlay( const onVisibility = () => { if (left) return; if (document.visibilityState === 'hidden') { - if (!refresher) return; heartbeat.pause(); - refresher.stop(); + refresher?.stop(); refresher = undefined; + // Already marked hidden. A page that reconnects in the background has no + // refresher but is not marked yet, so this checks the mark, not the refresher. + if (stillHidden !== undefined) return; reportVisibility(true); stillHidden = outsideAngular(() => setInterval(() => reportVisibility(true), HIDDEN_HEARTBEAT_MS), @@ -846,6 +848,7 @@ async function startOverlay( outsideAngular(heartbeat.start); if (refresher) return; clearInterval(stillHidden); + stillHidden = undefined; reportVisibility(false); pushAll(); refresher = watch(); @@ -1045,6 +1048,7 @@ async function startOverlay( refresher?.stop(); refresher = undefined; clearInterval(stillHidden); + stillHidden = undefined; reportVisibility(false); pipes?.pause(); forms?.leave(); @@ -1091,12 +1095,19 @@ async function startOverlay( }; addEventListener('pageshow', resendConfig); // After a server restart: stop sending, then claim the page and send everything again. + let drops = 0; link.follow({ - dropped: leave, + dropped: () => { + drops++; + leave(); + }, restored: async () => { + const at = drops; await claimPageOnServer(my.rpc, pageId, pageKey); // A failed call keeps the config the page already has. const next = await serverRedaction(my.rpc, fallbackRedaction); + // The link dropped again during the calls: stay quiet until the next `restored`. + if (drops !== at) return; if (next) setRedaction(next); resendConfig(); },