Skip to content

Commit a684a42

Browse files
nattb8claude
andauthored
feat(audience): add page-unload flush to MessageQueue (#2815)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 20626d9 commit a684a42

2 files changed

Lines changed: 217 additions & 0 deletions

File tree

packages/audience/core/src/queue.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,164 @@ describe('MessageQueue', () => {
158158
expect(queue.length).toBe(1);
159159
});
160160
});
161+
162+
describe('page-unload flush', () => {
163+
let sendBeaconSpy: jest.SpyInstance;
164+
165+
beforeEach(() => {
166+
sendBeaconSpy = jest.fn().mockReturnValue(true);
167+
Object.defineProperty(navigator, 'sendBeacon', {
168+
value: sendBeaconSpy,
169+
writable: true,
170+
configurable: true,
171+
});
172+
});
173+
174+
afterEach(() => {
175+
sendBeaconSpy.mockRestore?.();
176+
});
177+
178+
it('flushes via sendBeacon on visibilitychange to hidden', () => {
179+
const send = jest.fn().mockResolvedValue(true);
180+
const queue = createQueue({ send });
181+
queue.start();
182+
183+
queue.enqueue(makeMessage('1'));
184+
185+
Object.defineProperty(document, 'visibilityState', {
186+
value: 'hidden',
187+
writable: true,
188+
configurable: true,
189+
});
190+
document.dispatchEvent(new Event('visibilitychange'));
191+
192+
expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
193+
expect(sendBeaconSpy).toHaveBeenCalledWith(
194+
'https://api.immutable.com/v1/audience/messages',
195+
expect.any(Blob),
196+
);
197+
expect(queue.length).toBe(0);
198+
199+
queue.stop();
200+
Object.defineProperty(document, 'visibilityState', {
201+
value: 'visible',
202+
writable: true,
203+
configurable: true,
204+
});
205+
});
206+
207+
it('flushes via sendBeacon on pagehide', () => {
208+
const send = jest.fn().mockResolvedValue(true);
209+
const queue = createQueue({ send });
210+
queue.start();
211+
212+
queue.enqueue(makeMessage('1'));
213+
window.dispatchEvent(new Event('pagehide'));
214+
215+
expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
216+
expect(queue.length).toBe(0);
217+
218+
queue.stop();
219+
});
220+
221+
it('does not fire beacon when queue is empty', () => {
222+
const send = jest.fn().mockResolvedValue(true);
223+
const queue = createQueue({ send });
224+
queue.start();
225+
226+
window.dispatchEvent(new Event('pagehide'));
227+
228+
expect(sendBeaconSpy).not.toHaveBeenCalled();
229+
230+
queue.stop();
231+
});
232+
233+
it('removes listeners on stop', () => {
234+
const send = jest.fn().mockResolvedValue(true);
235+
const queue = createQueue({ send });
236+
queue.start();
237+
queue.stop();
238+
239+
queue.enqueue(makeMessage('1'));
240+
window.dispatchEvent(new Event('pagehide'));
241+
242+
expect(sendBeaconSpy).not.toHaveBeenCalled();
243+
});
244+
245+
it('destroy stops the queue and flushes remaining messages', () => {
246+
const send = jest.fn().mockResolvedValue(true);
247+
const queue = createQueue({ send });
248+
queue.start();
249+
250+
queue.enqueue(makeMessage('1'));
251+
queue.enqueue(makeMessage('2'));
252+
queue.destroy();
253+
254+
expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
255+
expect(queue.length).toBe(0);
256+
257+
// Listeners removed — no double flush
258+
queue.enqueue(makeMessage('3'));
259+
window.dispatchEvent(new Event('pagehide'));
260+
expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
261+
});
262+
263+
it('falls back to async flush if sendBeacon returns false', async () => {
264+
sendBeaconSpy.mockReturnValue(false);
265+
const send = jest.fn().mockResolvedValue(true);
266+
const queue = createQueue({ send });
267+
queue.start();
268+
269+
queue.enqueue(makeMessage('1'));
270+
window.dispatchEvent(new Event('pagehide'));
271+
272+
// sendBeacon failed, so async flush should have been triggered
273+
await Promise.resolve();
274+
expect(send).toHaveBeenCalledTimes(1);
275+
276+
queue.stop();
277+
});
278+
279+
it('falls back to async flush if sendBeacon is unavailable', async () => {
280+
Object.defineProperty(navigator, 'sendBeacon', {
281+
value: undefined,
282+
writable: true,
283+
configurable: true,
284+
});
285+
286+
const send = jest.fn().mockResolvedValue(true);
287+
const queue = createQueue({ send });
288+
queue.start();
289+
290+
queue.enqueue(makeMessage('1'));
291+
window.dispatchEvent(new Event('pagehide'));
292+
293+
await Promise.resolve();
294+
expect(send).toHaveBeenCalledTimes(1);
295+
296+
queue.stop();
297+
});
298+
299+
it('skips beacon if an async flush is already in flight', async () => {
300+
let resolveFlush: () => void;
301+
const flushPromise = new Promise<boolean>((r) => { resolveFlush = () => r(true); });
302+
const send = jest.fn().mockReturnValueOnce(flushPromise);
303+
304+
const queue = createQueue({ send });
305+
queue.start();
306+
queue.enqueue(makeMessage('1'));
307+
308+
// Start an async flush (sets flushing = true)
309+
const pending = queue.flush();
310+
311+
// pagehide fires while async flush is in flight — beacon should be skipped
312+
window.dispatchEvent(new Event('pagehide'));
313+
expect(sendBeaconSpy).not.toHaveBeenCalled();
314+
315+
resolveFlush!();
316+
await pending;
317+
expect(queue.length).toBe(0);
318+
319+
queue.stop();
320+
});
321+
});

packages/audience/core/src/queue.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Message, BatchPayload } from './types';
22
import type { Transport } from './transport';
33
import * as storage from './storage';
4+
import { isBrowser } from './utils';
45

56
const STORAGE_KEY = 'queue';
67

@@ -14,6 +15,10 @@ const STORAGE_KEY = 'queue';
1415
* localStorage is used as a write-through cache so messages survive
1516
* page navigations. On construction, any previously-persisted messages
1617
* are restored into memory.
18+
*
19+
* When started, the queue also listens for page-unload events
20+
* (`visibilitychange` and `pagehide`) and flushes via `sendBeacon`
21+
* to ensure events are not lost when the user navigates away.
1722
*/
1823
export class MessageQueue {
1924
private messages: Message[];
@@ -22,6 +27,16 @@ export class MessageQueue {
2227

2328
private flushing = false;
2429

30+
private readonly onVisibilityChange = (): void => {
31+
if (document.visibilityState === 'hidden') {
32+
this.flushBeacon();
33+
}
34+
};
35+
36+
private readonly onPageHide = (): void => {
37+
this.flushBeacon();
38+
};
39+
2540
constructor(
2641
private readonly transport: Transport,
2742
private readonly endpointUrl: string,
@@ -35,12 +50,28 @@ export class MessageQueue {
3550
start(): void {
3651
if (this.timer) return;
3752
this.timer = setInterval(() => this.flush(), this.flushIntervalMs);
53+
54+
if (isBrowser()) {
55+
document.addEventListener('visibilitychange', this.onVisibilityChange);
56+
window.addEventListener('pagehide', this.onPageHide);
57+
}
3858
}
3959

4060
stop(): void {
4161
if (!this.timer) return;
4262
clearInterval(this.timer);
4363
this.timer = null;
64+
65+
if (isBrowser()) {
66+
document.removeEventListener('visibilitychange', this.onVisibilityChange);
67+
window.removeEventListener('pagehide', this.onPageHide);
68+
}
69+
}
70+
71+
/** Stops the queue, flushes remaining messages via beacon, and removes listeners. */
72+
destroy(): void {
73+
this.stop();
74+
this.flushBeacon();
4475
}
4576

4677
enqueue(message: Message): void {
@@ -81,6 +112,31 @@ export class MessageQueue {
81112
storage.removeItem(STORAGE_KEY);
82113
}
83114

115+
/**
116+
* Synchronous flush using sendBeacon for page-unload scenarios.
117+
* sendBeacon is fire-and-forget and survives page navigation.
118+
* Falls back to the normal async flush if sendBeacon is unavailable.
119+
*/
120+
private flushBeacon(): void {
121+
if (this.flushing || this.messages.length === 0) return;
122+
123+
const payload: BatchPayload = { messages: [...this.messages] };
124+
const body = JSON.stringify(payload);
125+
126+
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
127+
const blob = new Blob([body], { type: 'application/json' });
128+
const sent = navigator.sendBeacon(this.endpointUrl, blob);
129+
if (sent) {
130+
this.messages = [];
131+
this.persist();
132+
return;
133+
}
134+
}
135+
136+
// Fallback: trigger async flush (best-effort, may not complete before unload)
137+
this.flush();
138+
}
139+
84140
private persist(): void {
85141
storage.setItem(STORAGE_KEY, this.messages);
86142
}

0 commit comments

Comments
 (0)