Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions packages/devtools/src/__tests__/overlay-reconnect-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> | 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<typeof import('../change-detection.ts')>();
return {
...actual,
watchChangeDetection: (options: Parameters<typeof actual.watchChangeDetection>[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<void>((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);
});
});
15 changes: 15 additions & 0 deletions packages/devtools/src/__tests__/overlay-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
17 changes: 14 additions & 3 deletions packages/devtools/src/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -846,6 +848,7 @@ async function startOverlay(
outsideAngular(heartbeat.start);
if (refresher) return;
clearInterval(stillHidden);
stillHidden = undefined;
reportVisibility(false);
pushAll();
refresher = watch();
Expand Down Expand Up @@ -1045,6 +1048,7 @@ async function startOverlay(
refresher?.stop();
refresher = undefined;
clearInterval(stillHidden);
stillHidden = undefined;
reportVisibility(false);
pipes?.pause();
forms?.leave();
Expand Down Expand Up @@ -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();
},
Expand Down
Loading