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
7 changes: 7 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,3 +531,10 @@ export function untrack<T>(fn: () => T): T {
popSubscriber();
}
}

// ---------------------------------------------------------------------------
// Shared signals (cross-tab sync via BroadcastChannel)
// ---------------------------------------------------------------------------

export { sharedSignal } from './shared.js';
export type { SharedSignal, SharedSignalOptions } from './shared.js';
209 changes: 209 additions & 0 deletions packages/core/src/shared.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// ============================================================================
// shared.test.ts — Tests for sharedSignal (cross-tab state sync)
// ============================================================================

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { effect } from './index';
import { sharedSignal, type SharedSignal } from './shared';

// ---------------------------------------------------------------------------
// BroadcastChannel mock
// ---------------------------------------------------------------------------

// Track all channels by name so we can simulate cross-tab messaging.
const channels: Map<
string,
Set<{ onmessage: ((e: MessageEvent) => void) | null; postMessage: (data: any) => void }>
> = new Map();

class MockBroadcastChannel {
name: string;
onmessage: ((e: MessageEvent) => void) | null = null;

constructor(name: string) {
this.name = name;
if (!channels.has(name)) {
channels.set(name, new Set());
}
channels.get(name)!.add(this);
}

postMessage(data: any): void {
const group = channels.get(this.name);
if (!group) return;
for (const ch of group) {
if (ch !== this && ch.onmessage) {
// Simulate async delivery (like real BroadcastChannel).
ch.onmessage(new MessageEvent('message', { data }));
}
}
}

close(): void {
const group = channels.get(this.name);
if (group) {
group.delete(this);
if (group.size === 0) channels.delete(this.name);
}
}
}

beforeEach(() => {
channels.clear();
(globalThis as any).BroadcastChannel = MockBroadcastChannel;
});

afterEach(() => {
channels.clear();
});

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('sharedSignal', () => {
it('behaves as a normal signal for reads and writes', () => {
const s = sharedSignal('test-basic', 0);
expect(s()).toBe(0);
expect(s.peek()).toBe(0);

s.set(42);
expect(s()).toBe(42);

s.update((n) => n + 1);
expect(s()).toBe(43);

s.close();
});

it('syncs value from one tab to another', () => {
const tab1 = sharedSignal('test-sync', 'hello');
const tab2 = sharedSignal('test-sync', 'hello');

tab1.set('world');
expect(tab2()).toBe('world');

tab1.close();
tab2.close();
});

it('syncs in both directions', () => {
const tab1 = sharedSignal('test-bidir', 0);
const tab2 = sharedSignal('test-bidir', 0);

tab1.set(10);
expect(tab2()).toBe(10);

tab2.set(20);
expect(tab1()).toBe(20);

tab1.close();
tab2.close();
});

it('triggers reactive effects on remote updates', () => {
const tab1 = sharedSignal('test-effect', 'a');
const tab2 = sharedSignal('test-effect', 'a');

const values: string[] = [];
const dispose = effect(() => {
values.push(tab2());
});

expect(values).toEqual(['a']);

tab1.set('b');
expect(values).toEqual(['a', 'b']);

dispose();
tab1.close();
tab2.close();
});

it('update() broadcasts the new value', () => {
const tab1 = sharedSignal('test-update', 5);
const tab2 = sharedSignal('test-update', 5);

tab1.update((n) => n * 2);
expect(tab2()).toBe(10);

tab1.close();
tab2.close();
});

it('does not echo remote updates back to the channel', () => {
const tab1 = sharedSignal('test-no-echo', 0);
const tab2 = sharedSignal('test-no-echo', 0);

// Spy on postMessage of tab2's underlying channel.
const group = channels.get('utopia:shared:test-no-echo')!;
const tab2Channel = Array.from(group)[1];
const postSpy = vi.spyOn(tab2Channel, 'postMessage');

// When tab1 sets, tab2 receives but should NOT re-broadcast.
tab1.set(99);
expect(tab2()).toBe(99);
expect(postSpy).not.toHaveBeenCalled();

postSpy.mockRestore();
tab1.close();
tab2.close();
});

it('supports custom serialization', () => {
const tab1 = sharedSignal('test-custom', new Date('2025-01-01'), {
serialize: (d) => d.toISOString(),
deserialize: (s) => new Date(s),
});
const tab2 = sharedSignal('test-custom', new Date('2000-01-01'), {
serialize: (d) => d.toISOString(),
deserialize: (s) => new Date(s),
});

tab1.set(new Date('2030-06-15'));
expect(tab2().toISOString()).toBe('2030-06-15T00:00:00.000Z');

tab1.close();
tab2.close();
});

it('stops syncing after close()', () => {
const tab1 = sharedSignal('test-close', 0);
const tab2 = sharedSignal('test-close', 0);

tab2.close();

tab1.set(42);
// tab2 should NOT have updated since its channel is closed.
expect(tab2()).toBe(0);

tab1.close();
});

it('works as a plain signal when BroadcastChannel is unavailable', () => {
// Remove BroadcastChannel to simulate SSR / unsupported env.
delete (globalThis as any).BroadcastChannel;

const s = sharedSignal('test-no-bc', 'hello');
expect(s()).toBe('hello');
s.set('world');
expect(s()).toBe('world');

// close() should not throw.
s.close();
});

it('syncs across 3+ tabs', () => {
const tab1 = sharedSignal('test-multi', 0);
const tab2 = sharedSignal('test-multi', 0);
const tab3 = sharedSignal('test-multi', 0);

tab1.set(7);
expect(tab2()).toBe(7);
expect(tab3()).toBe(7);

tab1.close();
tab2.close();
tab3.close();
});
});
112 changes: 112 additions & 0 deletions packages/core/src/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// ============================================================================
// @matthesketh/utopia-core — Shared signals (cross-tab state sync)
// ============================================================================
//
// Provides `sharedSignal()`, a signal that automatically synchronizes its
// value across browser tabs/windows of the same origin using the
// BroadcastChannel API.
//
// On the server (SSR) or in environments without BroadcastChannel, it
// falls back to a regular signal with no cross-tab behavior.
// ============================================================================

import { signal, type Signal } from './index.js';

/**
* Options for creating a shared signal.
*/
export interface SharedSignalOptions<T> {
/** Custom serializer (default: JSON.stringify). */
serialize?: (value: T) => string;
/** Custom deserializer (default: JSON.parse). */
deserialize?: (raw: string) => T;
}

/**
* A shared signal that syncs across browser tabs via BroadcastChannel.
* Extends the standard Signal interface with a `close()` method to
* tear down the channel.
*/
export interface SharedSignal<T> extends Signal<T> {
/** Close the BroadcastChannel and stop syncing. */
close(): void;
}

/**
* Creates a reactive signal that synchronizes its value across browser
* tabs/windows using the BroadcastChannel API.
*
* ```ts
* const theme = sharedSignal('theme', 'light');
*
* // Setting in one tab updates all other tabs:
* theme.set('dark');
*
* // Clean up when done:
* theme.close();
* ```
*
* @param key A unique channel name for this shared state.
* @param initialValue The initial value (used if no other tab has broadcast yet).
* @param options Optional custom serialization.
* @returns A SharedSignal with cross-tab sync.
*/
export function sharedSignal<T>(
key: string,
initialValue: T,
options?: SharedSignalOptions<T>,
): SharedSignal<T> {
const serialize = options?.serialize ?? JSON.stringify;
const deserialize = options?.deserialize ?? JSON.parse;

const inner = signal<T>(initialValue);

// No BroadcastChannel (SSR / older browsers) — return a plain signal.
if (typeof BroadcastChannel === 'undefined') {
return Object.assign(inner, { close: () => {} }) as SharedSignal<T>;
}

const channel = new BroadcastChannel(`utopia:shared:${key}`);
let isRemoteUpdate = false;

// Listen for updates from other tabs.
channel.onmessage = (event: MessageEvent) => {
try {
const value = deserialize(event.data);
isRemoteUpdate = true;
inner.set(value);
isRemoteUpdate = false;
} catch {
// Ignore malformed messages.
}
};

// Wrap .set() to broadcast changes to other tabs.
const originalSet = inner.set.bind(inner);
const broadcastSet = (newValue: T): void => {
originalSet(newValue);
// Only broadcast if this was a local change (not a remote update).
if (!isRemoteUpdate) {
try {
channel.postMessage(serialize(newValue));
} catch {
// Ignore serialization failures.
}
}
};

// Wrap .update() to go through broadcastSet.
const broadcastUpdate = (fn: (current: T) => T): void => {
broadcastSet(fn(inner.peek()));
};

// Build the SharedSignal by re-assigning set/update and adding close.
const shared = inner as unknown as SharedSignal<T>;
(shared as any).set = broadcastSet;
(shared as any).update = broadcastUpdate;
(shared as any).close = () => {
channel.close();
};

return shared;
}