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
5 changes: 5 additions & 0 deletions .changeset/queue-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ciolabs/framecast': minor
---

Add `queueMessages` option to `waitForReady()` and public `clearQueue()` method. When `queueMessages: true`, `broadcast()` calls are automatically queued until the handshake completes, then flushed. On timeout, queued messages are discarded.
100 changes: 100 additions & 0 deletions packages/framecast/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,106 @@ describe('Framecast', () => {
});
});

describe('queueMessages', () => {
it('queues broadcasts while waiting and flushes on ready', async () => {
const readyPromise = framecast.waitForReady({ interval: 10, timeout: 1000, queueMessages: true });

// These broadcasts should be queued, not sent
framecast.broadcast({ type: 'msg1', data: 'first' });
framecast.broadcast({ type: 'msg2', data: 'second' });

// Verify nothing was sent yet (only poll calls, no broadcast messages)
const broadcastsBefore = mockTargetWindow.postMessage.mock.calls.filter(call => {
const message = superjson.parse(call[0]) as any;
return message.type === 'broadcast' && message.data?.type !== '__framecast_ready';
});
expect(
broadcastsBefore.filter(c => {
const m = superjson.parse(c[0]) as any;
return m.data?.type === 'msg1' || m.data?.type === 'msg2';
})
).toHaveLength(0);

// Signal ready
simulateMessage('broadcast', { data: { type: '__framecast_ready' } });
await readyPromise;

// Now the queued messages should have been flushed
const broadcastsAfter = mockTargetWindow.postMessage.mock.calls.filter(call => {
const message = superjson.parse(call[0]) as any;
return message.type === 'broadcast' && (message.data?.type === 'msg1' || message.data?.type === 'msg2');
});
expect(broadcastsAfter).toHaveLength(2);
});

it('does not flush queued messages on timeout', async () => {
const readyPromise = framecast.waitForReady({ interval: 10, timeout: 100, queueMessages: true });

framecast.broadcast({ type: 'queued', data: 'value' });

// Wait for timeout
await expect(readyPromise).rejects.toThrow('waitForReady timed out');

// Message should NOT have been flushed — iframe isn't ready
const flushed = mockTargetWindow.postMessage.mock.calls.filter(call => {
const message = superjson.parse(call[0]) as any;
return message.type === 'broadcast' && message.data?.type === 'queued';
});
expect(flushed).toHaveLength(0);
});

it('clearQueue discards queued messages', () => {
framecast.waitForReady({ interval: 10, timeout: 1000, queueMessages: true });

framecast.broadcast({ type: 'will-discard', data: 'value' });
framecast.clearQueue();

// Simulate ready — nothing should flush since queue was cleared
simulateMessage('broadcast', { data: { type: '__framecast_ready' } });

const sent = mockTargetWindow.postMessage.mock.calls.filter(call => {
const message = superjson.parse(call[0]) as any;
return message.type === 'broadcast' && message.data?.type === 'will-discard';
});
expect(sent).toHaveLength(0);
});

it('broadcasts normally after ready (no more queuing)', async () => {
const readyPromise = framecast.waitForReady({ interval: 10, timeout: 1000, queueMessages: true });

simulateMessage('broadcast', { data: { type: '__framecast_ready' } });
await readyPromise;

// Clear mock to only track new calls
mockTargetWindow.postMessage.mockClear();

// This should go through immediately, not be queued
framecast.broadcast({ type: 'after-ready', data: 'direct' });

const sent = mockTargetWindow.postMessage.mock.calls.filter(call => {
const message = superjson.parse(call[0]) as any;
return message.type === 'broadcast' && message.data?.type === 'after-ready';
});
expect(sent).toHaveLength(1);
});

it('does not queue when queueMessages is false', async () => {
const readyPromise = framecast.waitForReady({ interval: 10, timeout: 1000, queueMessages: false });

framecast.broadcast({ type: 'not-queued', data: 'value' });

// Should be sent immediately
const sent = mockTargetWindow.postMessage.mock.calls.filter(call => {
const message = superjson.parse(call[0]) as any;
return message.type === 'broadcast' && message.data?.type === 'not-queued';
});
expect(sent).toHaveLength(1);

simulateMessage('broadcast', { data: { type: '__framecast_ready' } });
await readyPromise;
});
});

describe('state management', () => {
it('creates state atoms with initial values', () => {
const initialValue = { count: 0 };
Expand Down
92 changes: 63 additions & 29 deletions packages/framecast/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ export class Framecast {
*/
private pendingFunctionCalls: Map<string, { timeout: number; resolve: Function; reject: Function }> = new Map();

/**
* When true, broadcast() queues messages instead of posting them.
* Set by waitForReady({ queueMessages: true }), cleared on
* handshake success (flush), timeout (discard), or clearQueue().
*/
private _queueBroadcasts = false;
private _broadcastQueue: any[] = [];

constructor(target: Window, config?: Partial<FramecastConfig>) {
if (!target) {
throw new Error(`Framecast must be initialized with a window object`);
Expand Down Expand Up @@ -146,6 +154,10 @@ export class Framecast {
* @param data Message to send.
*/
broadcast(data: any): void {
if (this._queueBroadcasts) {
this._broadcastQueue.push(data);
return;
}
this.postMessage('broadcast', { data });
}

Expand Down Expand Up @@ -241,61 +253,83 @@ export class Framecast {
*
* @param options.interval Polling interval in ms (default: 50)
* @param options.timeout Timeout in ms (default: 10000). Set to 0 to wait indefinitely.
* @param options.queueMessages When true, broadcast() calls made while
* waiting are queued and automatically flushed once the handshake
* completes successfully. On timeout, queued messages are discarded.
*/
waitForReady(options?: { interval?: number; timeout?: number }): Promise<void> {
waitForReady(options?: { interval?: number; timeout?: number; queueMessages?: boolean }): Promise<void> {
const interval = options?.interval ?? 50;
const timeout = options?.timeout ?? 10_000;

if (options?.queueMessages) {
this._queueBroadcasts = true;
}

return new Promise<void>((resolve, reject) => {
let resolved = false;
const off = this.off.bind(this);
let done = false;

const onReady = () => {
if (done) return;
done = true;
teardown();
// Flush queued messages — iframe is ready to receive them.
const queued = this._broadcastQueue;
this._broadcastQueue = [];
this._queueBroadcasts = false;
for (const data of queued) {
this.postMessage('broadcast', { data });
}
resolve();
};

const onTimeout = () => {
if (done) return;
done = true;
teardown();
// Discard queued messages — iframe isn't ready, they'd be lost.
this.clearQueue();
reject(new Error(`waitForReady timed out after ${timeout}ms`));
};

const poll = () => {
if (resolved) return;
if (done) return;
this.call('__framecast_ready')
.then(() => {
if (!resolved) {
cleanup();
resolve();
}
})
.then(onReady)
.catch(() => {
// Expected when the iframe hasn't set up its listener yet.
// The poll will retry on the next interval.
// Expected — iframe hasn't set up its listener yet.
});
};

const broadcastListener = (message: any) => {
if (resolved) return;
if (message && typeof message === 'object' && message.type === '__framecast_ready') {
cleanup();
resolve();
onReady();
}
};

// Start polling and listening
this.on('broadcast', broadcastListener);
poll();
const pollTimer = setInterval(poll, interval);
const timeoutTimer =
timeout > 0
? setTimeout(() => {
if (!resolved) {
cleanup();
reject(new Error(`waitForReady timed out after ${timeout}ms`));
}
}, timeout)
: undefined;

function cleanup() {
resolved = true;
const timeoutTimer = timeout > 0 ? setTimeout(onTimeout, timeout) : undefined;

const off = this.off.bind(this);
const teardown = () => {
clearInterval(pollTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
off('broadcast', broadcastListener);
}
};
});
}

/**
* Discard any queued broadcasts and disable queuing mode.
* Call this when tearing down (e.g. unmounting) to prevent stale
* messages from being flushed to a dead iframe.
*/
clearQueue(): void {
this._queueBroadcasts = false;
this._broadcastQueue = [];
}

/**
* Evaluates the given function in the context of the target window
* and returns the result.
Expand Down
Loading