A signal-based store API where canonical state lives in a SharedWorker and every connected tab gets a reactive mirror. Cross-tab state for free, with a Pinia-shaped surface and @preact/signals-core under the hood.
Status: experimental. Roughly 3.5 KB gzipped on top of the signals lib.
- Multi-tab state without ceremony. Opening the app in three tabs gives you three views of the same state. No
BroadcastChannelwiring, no manual sync. - Sync ergonomics over an async boundary.
store.count++looks synchronous and feels synchronous; the worker handshake is hidden behind microtask-batched ops + optimistic local updates. - Signals all the way down. Mirrors are real preact signals —
effect(),computed(), and signal-aware UI bindings work out of the box.
git clone git@github.com:Linh35/ssw.git
cd ssw
npm install
npm run devOpen the printed URL in two tabs. Click +1 in one; the other moves too.
// examples/counter.ts
import { defineStore } from '../src'
export const counterStore = defineStore('counter', ({ signal, computed }) => {
const count = signal(0)
const doubled = computed(() => count.value * 2)
const bump = (by: number) => { count.value += by }
const reset = () => { count.value = 0 }
const fetchRemote = async () => {
const r = await fetch('/state').then((x) => x.json())
count.value = r.count
}
return { count, doubled, bump, reset, fetchRemote }
})// examples/worker.ts — bundled as a SharedWorker entry
import { createHost } from '../src'
import { counterStore } from './counter'
createHost([counterStore])// examples/main.ts
import { createClient, effect } from '../src'
import { counterStore } from './counter'
const { useStore } = createClient(new URL('./worker.ts', import.meta.url))
const store = useStore(counterStore)
effect(() => console.log(store.count, store.doubled))
store.count++ // optimistic, propagates to other tabs
store.bump(3) // sync action — fire-and-forget
await store.fetchRemote() // async action — round-trips
await store.ready // wait for the first snapshot if neededsetup receives { signal, computed } and returns a record. Values are interpreted by shape:
| Returned value | Becomes |
|---|---|
signal(initial) |
writable state — store.foo reads, store.foo = x writes |
computed(fn) |
derived state — store.foo reads, no setter |
| plain function (sync) | optimistic action — runs locally and on the worker, no return value exposed |
async function |
async action — only runs on the worker, returns the awaited result |
The same definition module is imported in both the worker and the main thread. Setup must be deterministic — it executes in each context independently.
Connects to a SharedWorker at workerUrl. Returns { useStore }. The name lets multiple disjoint clients coexist (default "ssw").
Returns a plain object whose accessors are wired via Object.defineProperty to the local signal mirror. State keys are accessed as plain properties (with synchronous getters/setters), actions are methods, computed values are read-only properties.
useStore can only be called once per store id per client; call it once and pass the resulting object around.
A Promise<void> that resolves once the initial snapshot has been applied. It rejects if the worker doesn't recognise the store id — await store.ready is the right place to put error handling, e.g.:
const store = useStore(counterStore)
try {
await store.ready
} catch (err) {
// worker didn't know this store id, or the connection failed early
}Returns an object of the underlying Signal / ReadonlySignal instances. Use it when you need to hand a raw signal to another reactive system, or when you need peek() / subscribe() directly.
Call once inside the SharedWorker entry. Instantiates each store and binds SharedWorkerGlobalScope.connect.
Port-level entry points used by the test suite. They take any MessagePort (e.g. new MessageChannel().port1) and let you run the host/client without a real SharedWorker — handy for tests and for adapting to other transports.
// inside a test
import { bindHost, clientFromPort, defineStore } from '../src'
const def = defineStore('x', ({ signal }) => ({ n: signal(0) }))
const onConnect = bindHost([def])
const ch = new MessageChannel()
onConnect(ch.port2) // wire the host to one end
const { useStore } = clientFromPort(ch.port1)
const store = useStore(def)
await store.ready
store.n = 7Vitest tests in this repo run end-to-end against MessageChannels constructed this way — no jsdom or SharedWorker shim required.
Re-exported so you don't have to depend on the underlying lib separately.
+----- main thread (per tab) -----+ +------ SharedWorker ------+
| | | |
| store.count = 5 | ops | signals (canonical) |
| └─ mirror signal updated |─────►| meta[key]: origin+seq |
| immediately (optimistic) | | |
| op queued for microtask | | set → batch() apply |
| | | call → action body |
| apply patch: | patch| |
| ├─ stale self-echo → drop |◄─────│ per-port effect emits |
| ├─ remote w/ unacked → skip | | the diff every tick |
| └─ otherwise → assign | | |
+---------------------------------+ +--------------------------+
Setup runs in both contexts. The worker uses its instance as the canonical state. The main thread uses its instance for shape detection and to seed the initial mirror; computeds and action closures stay connected to the same in-context signals so reactivity reconnects without any cross-realm tricks.
Ops protocol. Every client maintains a microtask-batched queue of operations:
| Op | When |
|---|---|
{kind:'set', key, value, seq} |
direct property assignment via the proxy setter |
{kind:'call', action, args, callId, seq} |
sync or async action invocation |
The whole queue ships in one {type:'ops'} message at the next microtask. Multiple writes to the same key inside that microtask collapse to the last value, so a slider drag becomes a single message.
One effect per store, broadcast to subscribers. The worker registers a single effect() per store at instantiation time, not one per connected port. When any signal changes, that effect runs the diff against lastValues once and postMessages the patch to every entry in the store's subscribers: Set<MessagePort>. With 100 tabs subscribed, that's one effect fire and one diff loop per write, not 100.
Two ack channels. When the worker processes an op, the originating tab needs to know its seq has been acknowledged so that later remote writes are no longer skipped by the optimistic filter.
- Value-changed sets ack through the patch itself. The broadcast carries
originClientIdandoriginSeq; the originator advancesackedSeqdirectly from the patch metadata. - Idempotent sets (value already matched) don't trigger the effect, so the worker sends an explicit
{type: 'ack', storeId, seqs: {key: seq}}to the originating port after the ops batch is applied. - Sync actions run inside
batch()on the worker so their writes coalesce into a single effect fire and a single patch. Before the batch, the worker installs speculative meta{clientId, seq}for every signal key in the store, and restores the meta for any key the action didn't actually change once the batch closes. The emitted patch carries the correct origin/seq, so the originator'sackedSeqadvances directly from the patch metadata — no separateackmessage needed. - Async actions can't be wrapped in a batch (each
awaitwould unbatch). Their body's writes fire effects immediately and broadcast intermediate patches with whatever meta was there before the action ran (typically stale). Other subscribers apply those intermediates normally; the originator skips them via theackedSeqfilter. Once the action resolves, the worker sends an explicitackwith the action's seq for every key the diff shows changed, releasing the originator's filter.
Patch application is batched on the client too. When a multi-key patch arrives, the client wraps the per-key applyKeyState loop in batch() so consumer effect()s see all keys update at once and fire exactly once per patch — important for UI code that reads multiple store properties in the same reactive callback.
Per-key flicker filter. On the client, every applied patch is checked against two counters per key:
latestLocalSeq[k]— highestseqof any local write we've issued fork.ackedSeq[k]— highestseqwe've seen reflected back asoriginClientId === me.
The filter rules:
if originClientId === me:
if state.seq < latestLocalSeq[k]: drop (a stale echo of an older write)
else: apply, advance ackedSeq[k]
else (remote or initial snapshot):
if ackedSeq[k] < latestLocalSeq[k]: skip (we have unacked pending writes)
else: apply
This is what makes concurrent writes from multiple tabs converge cleanly to a single value without intermediate flicker.
- No persistence. State evaporates when the last tab closes. Add IndexedDB if you need it.
- SharedWorker support is the gating constraint. Safari 16+, Firefox, modern Chrome are fine. Older mobile contexts may not have it; there's no dedicated-worker fallback (it would lose cross-tab sync).
- Setup determinism.
Math.random,Date.now,fetch, anything stateful insidesetupwill diverge between the worker's instance and the main thread's. Sync actions inherit this rule — non-deterministic action bodies should beasync. - Value-replacement only.
store.items.push(x)mutates the array in place; the setter never fires. Usestore.items = [...store.items, x]. - Sets execute before calls within one microtask. If you write
store.x = 1; store.action(); store.x = 2;synchronously, the worker'saction()runs againstx = 2, notx = 1. Locally the action sawx = 1. To force ordering,awaitbetween them so the microtask flushes. - No port cleanup. When a tab closes the worker's per-port effect keeps a reference. MessagePort has no native close event; explicit "leave" messages or a heartbeat would fix it (not done yet).
- Single-author auth. Worker can't authenticate writers right now — every tab on the same origin can mutate any store.
- IndexedDB persistence layer.
- Port disconnect detection + cleanup.
- Devtools panel (stream of patches, time-travel).
- Per-signal effects on the worker for stores with very large key counts.
npm run dev # vite dev server
npm run build # production build
npm run typecheck # tsc --noEmit
npm test # vitest runMIT.