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
161 changes: 161 additions & 0 deletions packages/audience/core/src/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>((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();
});
});
56 changes: 56 additions & 0 deletions packages/audience/core/src/queue.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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[];
Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand Down
Loading