diff --git a/packages/audience/core/src/queue.test.ts b/packages/audience/core/src/queue.test.ts index 05425641f3..bf9abbe462 100644 --- a/packages/audience/core/src/queue.test.ts +++ b/packages/audience/core/src/queue.test.ts @@ -158,3 +158,164 @@ 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(); + }); + + 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 b3c513ec5c..e165aef83c 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.flushing || 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); }