From 944f91e46d312c8debf8c1d8056a3635cae77167 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Fri, 3 Apr 2026 18:46:54 +1300 Subject: [PATCH 1/2] feat(audience): add page-unload flush to MessageQueue Adds visibilitychange and pagehide listeners that flush queued events via navigator.sendBeacon on page unload, preventing event loss when users navigate away. Adds destroy() for clean teardown. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/core/src/queue.test.ts | 118 +++++++++++++++++++++++ packages/audience/core/src/queue.ts | 56 +++++++++++ 2 files changed, 174 insertions(+) diff --git a/packages/audience/core/src/queue.test.ts b/packages/audience/core/src/queue.test.ts index 05425641f3..f65e76c4ee 100644 --- a/packages/audience/core/src/queue.test.ts +++ b/packages/audience/core/src/queue.test.ts @@ -158,3 +158,121 @@ describe('MessageQueue', () => { expect(queue.length).toBe(1); }); }); + +describe('page-unload flush', () => { + let sendBeaconSpy: jest.SpyInstance; + + beforeEach(() => { + sendBeaconSpy = jest.fn().mockReturnValue(true); + Object.defineProperty(navigator, 'sendBeacon', { + value: sendBeaconSpy, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + sendBeaconSpy.mockRestore?.(); + }); + + it('flushes via sendBeacon on visibilitychange to hidden', () => { + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }); + queue.start(); + + queue.enqueue(makeMessage('1')); + + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + configurable: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + + expect(sendBeaconSpy).toHaveBeenCalledTimes(1); + expect(sendBeaconSpy).toHaveBeenCalledWith( + 'https://api.immutable.com/v1/audience/messages', + expect.any(Blob), + ); + expect(queue.length).toBe(0); + + queue.stop(); + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }); + }); + + it('flushes via sendBeacon on pagehide', () => { + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }); + queue.start(); + + queue.enqueue(makeMessage('1')); + window.dispatchEvent(new Event('pagehide')); + + expect(sendBeaconSpy).toHaveBeenCalledTimes(1); + expect(queue.length).toBe(0); + + queue.stop(); + }); + + it('does not fire beacon when queue is empty', () => { + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }); + queue.start(); + + window.dispatchEvent(new Event('pagehide')); + + expect(sendBeaconSpy).not.toHaveBeenCalled(); + + queue.stop(); + }); + + it('removes listeners on stop', () => { + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }); + queue.start(); + queue.stop(); + + queue.enqueue(makeMessage('1')); + window.dispatchEvent(new Event('pagehide')); + + expect(sendBeaconSpy).not.toHaveBeenCalled(); + }); + + it('destroy stops the queue and flushes remaining messages', () => { + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }); + queue.start(); + + queue.enqueue(makeMessage('1')); + queue.enqueue(makeMessage('2')); + queue.destroy(); + + expect(sendBeaconSpy).toHaveBeenCalledTimes(1); + expect(queue.length).toBe(0); + + // Listeners removed — no double flush + queue.enqueue(makeMessage('3')); + window.dispatchEvent(new Event('pagehide')); + expect(sendBeaconSpy).toHaveBeenCalledTimes(1); + }); + + it('falls back to async flush if sendBeacon returns false', async () => { + sendBeaconSpy.mockReturnValue(false); + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }); + queue.start(); + + queue.enqueue(makeMessage('1')); + window.dispatchEvent(new Event('pagehide')); + + // sendBeacon failed, so async flush should have been triggered + await Promise.resolve(); + expect(send).toHaveBeenCalledTimes(1); + + queue.stop(); + }); +}); diff --git a/packages/audience/core/src/queue.ts b/packages/audience/core/src/queue.ts index b3c513ec5c..97a50c07ee 100644 --- a/packages/audience/core/src/queue.ts +++ b/packages/audience/core/src/queue.ts @@ -1,6 +1,7 @@ import type { Message, BatchPayload } from './types'; import type { Transport } from './transport'; import * as storage from './storage'; +import { isBrowser } from './utils'; const STORAGE_KEY = 'queue'; @@ -14,6 +15,10 @@ const STORAGE_KEY = 'queue'; * localStorage is used as a write-through cache so messages survive * page navigations. On construction, any previously-persisted messages * are restored into memory. + * + * When started, the queue also listens for page-unload events + * (`visibilitychange` and `pagehide`) and flushes via `sendBeacon` + * to ensure events are not lost when the user navigates away. */ export class MessageQueue { private messages: Message[]; @@ -22,6 +27,16 @@ export class MessageQueue { private flushing = false; + private readonly onVisibilityChange = (): void => { + if (document.visibilityState === 'hidden') { + this.flushBeacon(); + } + }; + + private readonly onPageHide = (): void => { + this.flushBeacon(); + }; + constructor( private readonly transport: Transport, private readonly endpointUrl: string, @@ -35,12 +50,28 @@ export class MessageQueue { start(): void { if (this.timer) return; this.timer = setInterval(() => this.flush(), this.flushIntervalMs); + + if (isBrowser()) { + document.addEventListener('visibilitychange', this.onVisibilityChange); + window.addEventListener('pagehide', this.onPageHide); + } } stop(): void { if (!this.timer) return; clearInterval(this.timer); this.timer = null; + + if (isBrowser()) { + document.removeEventListener('visibilitychange', this.onVisibilityChange); + window.removeEventListener('pagehide', this.onPageHide); + } + } + + /** Stops the queue, flushes remaining messages via beacon, and removes listeners. */ + destroy(): void { + this.stop(); + this.flushBeacon(); } enqueue(message: Message): void { @@ -81,6 +112,31 @@ export class MessageQueue { storage.removeItem(STORAGE_KEY); } + /** + * Synchronous flush using sendBeacon for page-unload scenarios. + * sendBeacon is fire-and-forget and survives page navigation. + * Falls back to the normal async flush if sendBeacon is unavailable. + */ + private flushBeacon(): void { + if (this.messages.length === 0) return; + + const payload: BatchPayload = { messages: [...this.messages] }; + const body = JSON.stringify(payload); + + if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { + const blob = new Blob([body], { type: 'application/json' }); + const sent = navigator.sendBeacon(this.endpointUrl, blob); + if (sent) { + this.messages = []; + this.persist(); + return; + } + } + + // Fallback: trigger async flush (best-effort, may not complete before unload) + this.flush(); + } + private persist(): void { storage.setItem(STORAGE_KEY, this.messages); } From 71591888415360d4a882540eb865f310ec5ad4dc Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Fri, 3 Apr 2026 18:51:48 +1300 Subject: [PATCH 2/2] fix(audience): prevent double-send in flushBeacon and add missing tests - Skip beacon if an async flush is already in flight (this.flushing guard) - Add test for sendBeacon being unavailable (falls back to async flush) - Add test for beacon skipped when async flush is in flight Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/core/src/queue.test.ts | 43 ++++++++++++++++++++++++ packages/audience/core/src/queue.ts | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/audience/core/src/queue.test.ts b/packages/audience/core/src/queue.test.ts index f65e76c4ee..bf9abbe462 100644 --- a/packages/audience/core/src/queue.test.ts +++ b/packages/audience/core/src/queue.test.ts @@ -275,4 +275,47 @@ describe('page-unload flush', () => { queue.stop(); }); + + it('falls back to async flush if sendBeacon is unavailable', async () => { + Object.defineProperty(navigator, 'sendBeacon', { + value: undefined, + writable: true, + configurable: true, + }); + + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }); + queue.start(); + + queue.enqueue(makeMessage('1')); + window.dispatchEvent(new Event('pagehide')); + + await Promise.resolve(); + expect(send).toHaveBeenCalledTimes(1); + + queue.stop(); + }); + + it('skips beacon if an async flush is already in flight', async () => { + let resolveFlush: () => void; + const flushPromise = new Promise((r) => { resolveFlush = () => r(true); }); + const send = jest.fn().mockReturnValueOnce(flushPromise); + + const queue = createQueue({ send }); + queue.start(); + queue.enqueue(makeMessage('1')); + + // Start an async flush (sets flushing = true) + const pending = queue.flush(); + + // pagehide fires while async flush is in flight — beacon should be skipped + window.dispatchEvent(new Event('pagehide')); + expect(sendBeaconSpy).not.toHaveBeenCalled(); + + resolveFlush!(); + await pending; + expect(queue.length).toBe(0); + + queue.stop(); + }); }); diff --git a/packages/audience/core/src/queue.ts b/packages/audience/core/src/queue.ts index 97a50c07ee..e165aef83c 100644 --- a/packages/audience/core/src/queue.ts +++ b/packages/audience/core/src/queue.ts @@ -118,7 +118,7 @@ export class MessageQueue { * Falls back to the normal async flush if sendBeacon is unavailable. */ private flushBeacon(): void { - if (this.messages.length === 0) return; + if (this.flushing || this.messages.length === 0) return; const payload: BatchPayload = { messages: [...this.messages] }; const body = JSON.stringify(payload);