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(); },