@kontsedal/olas-cross-tab
BroadcastChannel-backed cache sync for @kontsedal/olas-core. When the app in one tab writes to a query or invalidates it, every other tab of the same origin sees the same change. No tab re-fetches, nothing touches storage, and no request reaches the server. SPEC §13.2.
This is the in-memory sibling to @kontsedal/olas-persist. Persistence mirrors durable state on the storage event; this mirrors the (much larger) in-memory query cache that never touches disk. Both are independently opt-in.
Install
pnpm add @kontsedal/olas-cross-tab @kontsedal/olas-core @preact/signals-core30-second example
import {
bindQuery,
createQuery,
createRoot,
defineController,
defineQuery,
queryEngine,
} from '@kontsedal/olas-core'
import { crossTabPlugin } from '@kontsedal/olas-cross-tab'
type User = { id: string; name: string }
// Opt the query in. Its `id` routes messages between tabs, so it must be
// the same in every tab's build.
const userQuery = defineQuery({
id: 'app/user',
key: (id: string) => ['user', id],
fetcher: ({ signal }, id: string) =>
fetch(`/api/user/${id}`, { signal }).then((r) => r.json() as Promise<User>),
meta: { crossTab: true },
})
const appController = defineController((ctx) => ({
user: createQuery(ctx, userQuery, () => ['me']),
users: bindQuery(ctx, userQuery),
}))
const root = createRoot(appController, {
queries: queryEngine(),
deps: {},
plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],
})
// Tab A:
root.api.users.write('me', (prev) => ({ id: 'me', ...prev, name: 'New' }))Tab B's subscribers see the new value on the next signal flush. No fetch fires in Tab B.
API
function crossTabPlugin(options: CrossTabOptions): OlasPlugin
type CrossTabOptions = {
channelName: string
onWarn?: (message: string, cause?: unknown) => void
channelFactory?: (name: string) => ChannelLike | undefined
maxPayloadBytes?: number
optimistic?: boolean
origins?: readonly string[]
validate?: (queryId: string, data: unknown) => boolean
}| Option | Default | What |
|---|---|---|
channelName | required | Name of the BroadcastChannel. Include a version suffix (my-app/v2) for clean cross-deploy isolation — receivers from a different deploy with a different channel name simply don't see each other's traffic. |
onWarn | console.warn | Called on non-fatal conditions: DataCloneError while broadcasting (the data isn't structured-cloneable), an oversized payload, a malformed inbound message, or one validate rejected. |
channelFactory | defaultChannelFactory (wraps BroadcastChannel in a browser tab or worker) | Override the channel constructor, for tests or a runtime the default skips. Return undefined to disable cross-tab (the plugin installs no hooks). See SSR and servers. |
maxPayloadBytes | 512 * 1024 | Soft cap on one outbound message, estimated by its JSON length. Over the cap, the plugin warns and still posts. Infinity turns the warning off. |
optimistic | true | Also mirror optimistic setData writes and their rollbacks, so peers show a pending edit before the server confirms it. A peer shows it as a guess of its own. With false, only canonical writes, commits and invalidations cross, and a canonical write made under a guess sends the data beneath it. |
origins | [] | Origins whose writes and invalidations are mirrored too: a plugin's name, or the origin a bindQuery handle was given. See Whose writes cross. |
validate | accept every payload | Check a peer's data before this tab writes it. Return false to drop the message, which is reported through onWarn. A validate that throws rejects the message. |
The plugin needs a query engine. A root created without queries: queryEngine() throws at createRoot, before any channel opens.
How it works
The plugin's onWrite and onInvalidate hooks post each mirrored change onto a BroadcastChannel. A receiving tab applies it through its own host.queries.write or host.queries.invalidate. That write carries the plugin's name, 'olas-cross-tab', as its origin. The send gate skips a write with that origin, so nothing echoes back.
Tab A: users.write(...) → cache write (origin: undefined) → plugin onWrite
↓
channel.postMessage(msg)
↓
━━━━━━━━━━━━━━━━━━━━━ BroadcastChannel ━━━━━━━━━━━━━━━━━━━━━
↓
Tab B: channel listener → validate(queryId, data) → host.queries.write(...)
cache write (origin: 'olas-cross-tab') → plugin onWrite → not mirroredWhat crosses
| Change | Crosses? |
|---|---|
write and replace (canonical) | Yes. A replace is applied as a replace, so it supersedes a fetch the receiving tab has in flight. |
setData (optimistic) and its rollback | Yes, unless optimistic: false. The receiving tab shows the guess as a guess of its own. |
| A commit, when a mutation succeeds | Yes. The receiving tab ends on the committed value. |
invalidate | Yes. The receiving tab marks the entry stale, and refetches it only if it has subscribers. |
| A fetch result | No. Every tab runs its own fetcher, so rebroadcasting results would be noise that changes nobody's cache. |
| Hydration | No. It is a per-tab concern too. |
A receiving tab applies a write only to an entry it already holds for that key, and creates no new entries. A subscriber that mounts later fetches as usual.
A peer's guess stays a guess
Each message carries the write's source, and the receiving tab applies it as what it is. A peer's optimistic write is shown through host.queries.setData, the way onMutate shows one. So in the receiving tab it restarts no stale clock, hasPendingMutations reads true, and persistQueryCachePlugin does not store it. The peer's rollback removes it, and the peer's commit makes the committed value the tab's own data, as a commit does: the stale clock stays where the server set it. A peer that closes before its mutation settles never sends either, so a guess its peer says nothing more about for 30 seconds is rolled back.
A canonical write made while the sender shows a guess carries the data beneath the guess too. The receiving tab writes that as server truth, and shows the rest as the peer's guess. A message from a version before 1.0 carries no source, and is applied as a write.
Whose writes cross
By default the plugin mirrors only the app's own writes: those whose origin is undefined. A write with an origin came from another plugin, or from a handle made with bindQuery(ctx, query, { origin }). Such a write is usually derived. A realtime push reaches every tab itself, so mirroring it would deliver it twice.
List an origin in origins to mirror its writes too. The entities plugin is the usual case: an entities.update(...) patch stays in its own tab until you opt in.
The entities default is opt-in for two reasons:
- An app write that crosses already reaches the peer's store. The peer's own entities plugin walks the mirrored write, as it walks any other write.
- An update that every tab makes for itself, such as one realtime push each tab folds into its store, would cross from every tab. With two tabs that is two messages and a second, redundant write in each tab, where the default sends none.
Opt in when one tab's UI makes the update and the other tabs have no other way to learn it.
import { crossTabPlugin } from '@kontsedal/olas-cross-tab'
import { ENTITIES_PLUGIN_NAME } from '@kontsedal/olas-entities'
const crossTab = crossTabPlugin({
channelName: 'my-app/cache/v1',
origins: [ENTITIES_PLUGIN_NAME],
})A write the plugin applied from a peer is not mirrored back, even with the plugin's own name in origins.
Echo prevention (three layers)
- Origin: a write the plugin applied from a peer carries
origin: 'olas-cross-tab', and the send side skips it. - Own-source drop: receivers filter messages by
sourceId. Every root picks a random one when the plugin sets up. If the transport echoes the message back, the sender ignores it. (sourceId, msgId)dedup: monotonicmsgIdpersourceIdlets receivers drop out-of-order or duplicate messages. A receiver moves a peer's cursor only for a message it applied.
Protocol versioning
Messages carry v: PROTOCOL_VERSION. Receivers drop messages with a v they don't understand. Channel names themselves are user-supplied; for cross-deploy isolation, embed a version in your channelName (e.g. 'app/cache/v2').
Non-cloneable data
BroadcastChannel uses structured clone. Cache data containing a function or a symbol throws DataCloneError at postMessage. A class instance does not throw; it arrives as a plain object without its prototype. The plugin catches the throw, calls onWarn(...), and drops the message. The sender's cache is unaffected — only the cross-tab echo is lost.
Messages from other scripts
Any same-origin script can post on the channel, so a receiving tab treats a message as possibly corrupt (SPEC §22):
- It drops a message whose protocol version,
sourceIdormsgIdis wrong. AmsgIdhas to be a safe non-negative integer. - It moves a peer's cursor only for a message it applied. A forged message with a huge
msgIdunder a real peer'ssourceIdtherefore moves nothing unless it is one the tab would apply. If it is, the real peer's next message lands far below the cursor. The receiver takes that as a wrong cursor, applies the message and restarts the cursor from it. One planted message cannot silence a peer. - It warns about a message with a bad
queryId,keyArgsorpageParams, and does not apply it. - It reports a message it cannot apply, such as a key the engine cannot hash, through
onWarn. Nothing throws out of the channel's handler. - It passes each payload to
validate, when one is given.
Olas does not check that a peer's data matches the query's type. validate and a versioned channelName are the tools for that:
import { crossTabPlugin } from '@kontsedal/olas-cross-tab'
const crossTab = crossTabPlugin({
channelName: 'my-app/cache/v1',
validate: (queryId, data) =>
queryId !== 'app/user' ||
(typeof data === 'object' && data !== null && typeof (data as { name?: unknown }).name === 'string'),
})Devtools
In a development build, the plugin reports every message on its lane in @kontsedal/olas-devtools, through host.debug. A tab reports each message it posts, and each message a peer sent on this protocol version:
{ kind: 'send', type: 'setData', source: 'write', queryId: 'app/user', outcome: 'posted', from: 'lq3k-7f2a', msgId: 4, key: ['user', 'me'] }
{ kind: 'receive', type: 'setData', source: 'write', queryId: 'app/user', outcome: 'applied', from: 'lq3k-7f2a', msgId: 4, key: ['user', 'me'] }from is the sending root's sourceId, so from and msgId name one message in the sender's lane and in every receiver's.
outcome | Meaning |
|---|---|
posted | Sent. |
not-cloneable | postMessage threw, and the message was dropped. |
applied | Written or invalidated in this tab. |
duplicate | Its msgId is at most the last one this tab applied from that peer, and less than 64 below it. |
malformed | A field has the wrong shape, or the message type is unknown. |
ignored | This tab has not bound the query, or has not opted it in. |
rejected | validate returned false or threw. |
failed | Applying it threw, such as on a key the engine cannot hash. |
A payload that is not an object, a message on another protocol version, and a tab's own echoed message are dropped without a lane event. The default build strips the calls.
Per-query opt-in
Two fields on the definition gate cross-tab behavior:
id: string— required on everydefineQueryanddefineInfiniteQuery. Messages route by it, so keep it stable and identical across tabs and deploys. Write it by hand: a name derived fromfetcher.namechanges under minification.meta: { crossTab: true }— the per-query gate. The package addscrossTabto core'sQueryMetatype. Without it, the plugin doesn't broadcast, so module-internal queries don't leak. The gate applies on both send and receive. A tab ignores inbound writes for queries its own build didn't opt in, so an opt-in mismatch across deploys can't push writes into unmarked queries.
Infinite queries opt in the same way. Their pages travel with their pageParams, so the receiving tab keeps paging from them. Page arrays can be large, and maxPayloadBytes warns about them.
SSR and servers
Without a channelFactory, the plugin opens a channel only in a browser tab (a scope with a document) or a web worker. Everywhere else it installs no hooks, the root still constructs, and cross-tab is off. So you can wire the plugin unconditionally in code the server and the browser share.
Servers are the reason for the rule. Node, Bun and Deno all define a global BroadcastChannel, and there it reaches every root in the process, plus other worker threads or isolates. A server that builds a root per request would put one user's writes into another user's render. The default therefore rules out Node, Bun and Deno, including their workers.
A server that installs a global document, such as through global-jsdom, looks like a browser tab to this check. Pass channelFactory: () => undefined there.
To open a channel where the default does not, pass a factory. BroadcastChannel fits ChannelLike as it is:
import { crossTabPlugin } from '@kontsedal/olas-cross-tab'
const crossTab = crossTabPlugin({
channelName: 'my-app/cache/v1',
channelFactory: (name) => new BroadcastChannel(name),
})Interaction with @kontsedal/olas-persist
These two layers solve different problems:
@kontsedal/olas-persistmirrors durable state vialocalStorage+ thestorageevent.@kontsedal/olas-cross-tabmirrors the in-memory query cache viaBroadcastChannel.
You can combine them on the same logical entity, but it's redundant — @kontsedal/olas-persist's cross-tab sync already covers the durable copy.
Conflict model — last-delivery-wins
Cross-tab sync is a broadcast, not a consensus protocol — think of it as "every tab refetched, but for free," not as a source of truth. There's no arbitration, no vector clocks, no server round-trip: each tab applies inbound writes in delivery order, and the last delivery wins for that tab. So two tabs that write the same entry concurrently can settle on different values until something reconciles them.
That something is a server refetch, and it's a one-liner: after a write that matters, call query.invalidate(...) (it broadcasts too), and every tab pulls authoritative server truth and re-converges. The mutation onError and onSuccess → invalidate pattern gives you this for free.
Limitations
- No structural diffs. Every write broadcasts the full post-update value. For chunky cache entries this is fine because
BroadcastChannelis in-memory; for very large arrays it's a known cost. - No pending-mutation arbitration. If two tabs run optimistic mutations on the same entry concurrently, the last write to arrive wins on both sides. Your mutation
onErrorandonSuccessthen re-syncs from the server, which restores convergence at the cost of a temporary divergence. - Optimistic writes cross tabs by default. Optimistic state and its rollback are visible in other tabs, as guesses. Pass
optimistic: falseto keep them local. - A peer's guess can expire before its mutation settles. It is rolled back after 30 seconds without a word from the peer. A mutation that takes longer shows its commit in the receiving tab when it lands.
Further reading
../../.wiki/modules/cross-tab.md- SPEC §13.2 — Cross-tab in-memory cache sync.
- SPEC §5.2 — Query definition (
id,meta). - SPEC §20.8 —
RootOptions.plugins. - SPEC §22 — the trust model for channel messages.