@kontsedal/olas-cross-tab 0.0.1-rc.1 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -131,14 +131,18 @@ function crossTabPlugin(options) {
131
131
  onWarn(`[olas/cross-tab] failed to broadcast ${msg.type} for queryId="${msg.queryId}": data is not structured-cloneable`, cause);
132
132
  }
133
133
  };
134
+ let initialized = false;
134
135
  return {
135
136
  init(a) {
137
+ if (initialized) throw new Error("[olas/cross-tab] crossTabPlugin instance reused across multiple roots. Each root must get its own `crossTabPlugin({ ... })`.");
138
+ initialized = true;
136
139
  api = a;
137
140
  channel = factory(channelName) ?? null;
138
141
  channel?.addEventListener("message", listener);
139
142
  },
140
143
  onSetData(event) {
141
144
  if (event.isRemote) return;
145
+ if (event.source === "fetch") return;
142
146
  if (event.kind !== "data") return;
143
147
  if (!shouldBroadcast(event.queryId)) return;
144
148
  send({
@@ -172,6 +176,7 @@ function crossTabPlugin(options) {
172
176
  channel = null;
173
177
  }
174
178
  api = null;
179
+ initialized = false;
175
180
  }
176
181
  };
177
182
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../src/channel.ts","../src/protocol.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Tiny `BroadcastChannel`-shaped abstraction. Lets tests inject a fake\n * (a shared in-memory bus across multiple \"tabs\" in the same process) and\n * keeps SSR-safety in one place — when `BroadcastChannel` is absent and\n * no `channelFactory` override is supplied, the plugin returns a no-op\n * variant up the stack.\n */\n\nexport type ChannelLike = {\n postMessage(data: unknown): void\n addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void\n removeEventListener(type: 'message', listener: (event: { data: unknown }) => void): void\n close(): void\n}\n\n/**\n * Default factory: wraps the platform `BroadcastChannel`. Returns\n * `undefined` when `BroadcastChannel` is not defined (SSR / Node without\n * `--experimental-broadcastchannel`, older browsers).\n */\nexport function defaultChannelFactory(name: string): ChannelLike | undefined {\n if (typeof BroadcastChannel === 'undefined') return undefined\n const ch = new BroadcastChannel(name)\n return {\n postMessage(data) {\n ch.postMessage(data)\n },\n addEventListener(type, listener) {\n // The platform BroadcastChannel typing wants a `MessageEvent`\n // listener, but we only care about `event.data` — cast through\n // `unknown` since the shapes don't structurally overlap.\n ch.addEventListener(type, listener as unknown as EventListener)\n },\n removeEventListener(type, listener) {\n ch.removeEventListener(type, listener as unknown as EventListener)\n },\n close() {\n ch.close()\n },\n }\n}\n","/**\n * Wire protocol for `@kontsedal/olas-cross-tab` messages. SPEC §13.2.\n *\n * `v` (protocol version) and `sourceId` (per-plugin-instance unique tag)\n * combine to make the three-layer echo prevention work:\n *\n * 1. Sender skips broadcast when `SetDataEvent.isRemote === true` (the\n * write originated from `applyRemoteSetData`).\n * 2. Receiver filters its own `sourceId` (catches the case where the\n * transport echoes the message back to the sender).\n * 3. Receiver dedupes by `(sourceId, msgId)` — duplicate or out-of-order\n * messages from the same peer are dropped.\n *\n * Receivers also drop messages with a `v` they don't understand. The\n * channel name itself is user-supplied; consumers who want clean\n * cross-deploy isolation should embed a version in their `channelName`.\n */\n\nexport const PROTOCOL_VERSION = 1\n\nexport type SetDataMessage = {\n v: typeof PROTOCOL_VERSION\n type: 'setData'\n sourceId: string\n msgId: number\n queryId: string\n keyArgs: readonly unknown[]\n data: unknown\n}\n\nexport type InvalidateMessage = {\n v: typeof PROTOCOL_VERSION\n type: 'invalidate'\n sourceId: string\n msgId: number\n queryId: string\n keyArgs: readonly unknown[]\n}\n\nexport type Message = SetDataMessage | InvalidateMessage\n","import {\n type GcEvent,\n type InvalidateEvent,\n lookupRegisteredQuery,\n type QueryClientPlugin,\n type QueryClientPluginApi,\n type SetDataEvent,\n} from '@kontsedal/olas-core'\nimport { type ChannelLike, defaultChannelFactory } from './channel'\nimport { type Message, PROTOCOL_VERSION } from './protocol'\n\n/**\n * Options accepted by `crossTabPlugin(...)`. SPEC §13.2.\n *\n * - `channelName` — name of the `BroadcastChannel`. Required. Users who\n * want clean cross-deploy isolation should include a version suffix\n * (e.g. `'my-app/cache/v2'`).\n * - `onWarn` — called for non-fatal conditions: a `DataCloneError` while\n * posting (the data isn't structured-cloneable), or a malformed\n * inbound message. Default: `console.warn`.\n * - `channelFactory` — override the channel constructor. Mainly for\n * tests that share an in-memory bus across two QueryClients.\n */\nexport type CrossTabOptions = {\n channelName: string\n onWarn?: (message: string, cause?: unknown) => void\n channelFactory?: (name: string) => ChannelLike | undefined\n}\n\n/**\n * Generate a unique-enough source id for a plugin instance. Combines\n * `Date.now()` with `Math.random()` — collisions across same-millisecond\n * tab-opens are negligible at one-decimal-place randomness, and even a\n * collision only loses dedup, not correctness.\n */\nfunction makeSourceId(): string {\n const rand = Math.random().toString(36).slice(2, 10)\n return `${Date.now().toString(36)}-${rand}`\n}\n\nconst NOOP_PLUGIN: QueryClientPlugin = {}\n\n/**\n * Cross-tab cache sync over `BroadcastChannel`. Mirrors `setData` /\n * `invalidate` writes across tabs of the same origin.\n *\n * Wire it up via `RootOptions.plugins`:\n *\n * ```ts\n * createRoot(appController, {\n * deps,\n * plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],\n * })\n * ```\n *\n * Queries must opt in via `defineQuery({ queryId: '<unique>', crossTab: true })`\n * — the `queryId` routes inbound messages back to the right query, and\n * `crossTab: true` is the per-query opt-in (queries that don't set it are\n * ignored by the sender so module-internal queries don't leak).\n *\n * **SSR safety.** When `BroadcastChannel` is not defined (Node, older\n * browsers without the feature) and no `channelFactory` override is\n * supplied, the function returns a no-op plugin object. The root still\n * boots cleanly; cross-tab is just disabled.\n *\n * **Non-cloneable data.** `BroadcastChannel` uses structured clone. Cache\n * data containing functions, class instances, or symbols throws a\n * `DataCloneError` at `postMessage`. The plugin catches the throw, calls\n * `onWarn(...)`, and drops the message — the sender's cache is unaffected.\n */\nexport function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {\n const channelName = options.channelName\n const onWarn = options.onWarn ?? defaultWarn\n const factory = options.channelFactory ?? defaultChannelFactory\n\n // Cheap probe — if the environment can't produce a channel at all we can\n // return a no-op plugin without opening anything. The real channel opens\n // lazily in `init` so a plugin that's constructed but never passed to a\n // root doesn't leak a BroadcastChannel.\n const probe = factory(channelName)\n if (!probe) {\n // SSR / unsupported environment. Caller's plugin slot still receives a\n // valid plugin object; it just does nothing.\n return NOOP_PLUGIN\n }\n probe.close()\n\n const sourceId = makeSourceId()\n let msgIdCounter = 0\n const seenByPeer = new Map<string, number>()\n let api: QueryClientPluginApi | null = null\n let channel: ChannelLike | null = null\n\n const listener = (event: { data: unknown }) => {\n const msg = event.data as Partial<Message> | null\n if (!msg || typeof msg !== 'object') return\n // Layer 1 — protocol version drop.\n if (msg.v !== PROTOCOL_VERSION) return\n // Layer 2 — own-source drop (transport echoed our own message back).\n if (msg.sourceId === sourceId) return\n // Layer 3 — out-of-order / duplicate drop.\n if (typeof msg.sourceId !== 'string' || typeof msg.msgId !== 'number') return\n const last = seenByPeer.get(msg.sourceId) ?? -1\n if (msg.msgId <= last) return\n seenByPeer.set(msg.sourceId, msg.msgId)\n\n if (msg.type === 'setData') {\n if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {\n onWarn('[olas/cross-tab] malformed setData message')\n return\n }\n api?.applyRemoteSetData(msg.queryId, msg.keyArgs, msg.data)\n return\n }\n if (msg.type === 'invalidate') {\n if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {\n onWarn('[olas/cross-tab] malformed invalidate message')\n return\n }\n api?.applyRemoteInvalidate(msg.queryId, msg.keyArgs)\n return\n }\n }\n\n const send = (msg: Message): void => {\n if (channel === null) return\n try {\n channel.postMessage(msg)\n } catch (cause) {\n // Structured clone failed — most likely non-cloneable data on a\n // setData payload. Warn and drop.\n onWarn(\n `[olas/cross-tab] failed to broadcast ${msg.type} for queryId=\"${msg.queryId}\": data is not structured-cloneable`,\n cause,\n )\n }\n }\n\n return {\n init(a) {\n api = a\n channel = factory(channelName) ?? null\n channel?.addEventListener('message', listener)\n },\n\n onSetData(event: SetDataEvent) {\n // Don't echo inbound writes — Layer-1 sender-side echo prevention.\n if (event.isRemote) return\n // Infinite queries are deferred for v1 — see SPEC §13.2.\n if (event.kind !== 'data') return\n if (!shouldBroadcast(event.queryId)) return\n\n send({\n v: PROTOCOL_VERSION,\n type: 'setData',\n sourceId,\n msgId: ++msgIdCounter,\n queryId: event.queryId,\n keyArgs: event.keyArgs,\n data: event.data,\n })\n },\n\n onInvalidate(event: InvalidateEvent) {\n if (event.isRemote) return\n if (event.kind !== 'data') return\n if (!shouldBroadcast(event.queryId)) return\n\n send({\n v: PROTOCOL_VERSION,\n type: 'invalidate',\n sourceId,\n msgId: ++msgIdCounter,\n queryId: event.queryId,\n keyArgs: event.keyArgs,\n })\n },\n\n onGc(_event: GcEvent) {\n // GC is local — we don't propagate it. Each tab gc's its own entries\n // when its own subscribers drop.\n },\n\n dispose() {\n if (channel !== null) {\n channel.removeEventListener('message', listener)\n channel.close()\n channel = null\n }\n api = null\n },\n }\n}\n\nfunction defaultWarn(message: string, cause?: unknown): void {\n if (cause !== undefined) {\n console.warn(message, cause)\n } else {\n console.warn(message)\n }\n}\n\n/**\n * Per-query gate. `crossTab: true` is a static opt-in on the spec; the\n * QueryClient doesn't filter on it (its events fire for every query that\n * has a `queryId`), so the plugin checks here. We look it up from the\n * core's query registry on every event — no caching, since the registry\n * lookup is a Map.get.\n */\nfunction shouldBroadcast(queryId: string): boolean {\n const registered = lookupRegisteredQuery(queryId)\n if (!registered) return false\n return registered.__spec.crossTab === true\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../src/channel.ts","../src/protocol.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Tiny `BroadcastChannel`-shaped abstraction. Lets tests inject a fake\n * (a shared in-memory bus across multiple \"tabs\" in the same process) and\n * keeps SSR-safety in one place — when `BroadcastChannel` is absent and\n * no `channelFactory` override is supplied, the plugin returns a no-op\n * variant up the stack.\n */\n\nexport type ChannelLike = {\n postMessage(data: unknown): void\n addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void\n removeEventListener(type: 'message', listener: (event: { data: unknown }) => void): void\n close(): void\n}\n\n/**\n * Default factory: wraps the platform `BroadcastChannel`. Returns\n * `undefined` when `BroadcastChannel` is not defined (SSR / Node without\n * `--experimental-broadcastchannel`, older browsers).\n */\nexport function defaultChannelFactory(name: string): ChannelLike | undefined {\n if (typeof BroadcastChannel === 'undefined') return undefined\n const ch = new BroadcastChannel(name)\n return {\n postMessage(data) {\n ch.postMessage(data)\n },\n addEventListener(type, listener) {\n // The platform BroadcastChannel typing wants a `MessageEvent`\n // listener, but we only care about `event.data` — cast through\n // `unknown` since the shapes don't structurally overlap.\n ch.addEventListener(type, listener as unknown as EventListener)\n },\n removeEventListener(type, listener) {\n ch.removeEventListener(type, listener as unknown as EventListener)\n },\n close() {\n ch.close()\n },\n }\n}\n","/**\n * Wire protocol for `@kontsedal/olas-cross-tab` messages. SPEC §13.2.\n *\n * `v` (protocol version) and `sourceId` (per-plugin-instance unique tag)\n * combine to make the three-layer echo prevention work:\n *\n * 1. Sender skips broadcast when `SetDataEvent.isRemote === true` (the\n * write originated from `applyRemoteSetData`).\n * 2. Receiver filters its own `sourceId` (catches the case where the\n * transport echoes the message back to the sender).\n * 3. Receiver dedupes by `(sourceId, msgId)` — duplicate or out-of-order\n * messages from the same peer are dropped.\n *\n * Receivers also drop messages with a `v` they don't understand. The\n * channel name itself is user-supplied; consumers who want clean\n * cross-deploy isolation should embed a version in their `channelName`.\n */\n\nexport const PROTOCOL_VERSION = 1\n\nexport type SetDataMessage = {\n v: typeof PROTOCOL_VERSION\n type: 'setData'\n sourceId: string\n msgId: number\n queryId: string\n keyArgs: readonly unknown[]\n data: unknown\n}\n\nexport type InvalidateMessage = {\n v: typeof PROTOCOL_VERSION\n type: 'invalidate'\n sourceId: string\n msgId: number\n queryId: string\n keyArgs: readonly unknown[]\n}\n\nexport type Message = SetDataMessage | InvalidateMessage\n","import {\n type GcEvent,\n type InvalidateEvent,\n lookupRegisteredQuery,\n type QueryClientPlugin,\n type QueryClientPluginApi,\n type SetDataEvent,\n} from '@kontsedal/olas-core'\nimport { type ChannelLike, defaultChannelFactory } from './channel'\nimport { type Message, PROTOCOL_VERSION } from './protocol'\n\n/**\n * Options accepted by `crossTabPlugin(...)`. SPEC §13.2.\n *\n * - `channelName` — name of the `BroadcastChannel`. Required. Users who\n * want clean cross-deploy isolation should include a version suffix\n * (e.g. `'my-app/cache/v2'`).\n * - `onWarn` — called for non-fatal conditions: a `DataCloneError` while\n * posting (the data isn't structured-cloneable), or a malformed\n * inbound message. Default: `console.warn`.\n * - `channelFactory` — override the channel constructor. Mainly for\n * tests that share an in-memory bus across two QueryClients.\n */\nexport type CrossTabOptions = {\n channelName: string\n onWarn?: (message: string, cause?: unknown) => void\n channelFactory?: (name: string) => ChannelLike | undefined\n}\n\n/**\n * Generate a unique-enough source id for a plugin instance. Combines\n * `Date.now()` with `Math.random()` — collisions across same-millisecond\n * tab-opens are negligible at one-decimal-place randomness, and even a\n * collision only loses dedup, not correctness.\n */\nfunction makeSourceId(): string {\n const rand = Math.random().toString(36).slice(2, 10)\n return `${Date.now().toString(36)}-${rand}`\n}\n\nconst NOOP_PLUGIN: QueryClientPlugin = {}\n\n/**\n * Cross-tab cache sync over `BroadcastChannel`. Mirrors `setData` /\n * `invalidate` writes across tabs of the same origin.\n *\n * Wire it up via `RootOptions.plugins`:\n *\n * ```ts\n * createRoot(appController, {\n * deps,\n * plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],\n * })\n * ```\n *\n * Queries must opt in via `defineQuery({ queryId: '<unique>', crossTab: true })`\n * — the `queryId` routes inbound messages back to the right query, and\n * `crossTab: true` is the per-query opt-in (queries that don't set it are\n * ignored by the sender so module-internal queries don't leak).\n *\n * **SSR safety.** When `BroadcastChannel` is not defined (Node, older\n * browsers without the feature) and no `channelFactory` override is\n * supplied, the function returns a no-op plugin object. The root still\n * boots cleanly; cross-tab is just disabled.\n *\n * **Non-cloneable data.** `BroadcastChannel` uses structured clone. Cache\n * data containing functions, class instances, or symbols throws a\n * `DataCloneError` at `postMessage`. The plugin catches the throw, calls\n * `onWarn(...)`, and drops the message — the sender's cache is unaffected.\n */\nexport function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {\n const channelName = options.channelName\n const onWarn = options.onWarn ?? defaultWarn\n const factory = options.channelFactory ?? defaultChannelFactory\n\n // Cheap probe — if the environment can't produce a channel at all we can\n // return a no-op plugin without opening anything. The real channel opens\n // lazily in `init` so a plugin that's constructed but never passed to a\n // root doesn't leak a BroadcastChannel.\n const probe = factory(channelName)\n if (!probe) {\n // SSR / unsupported environment. Caller's plugin slot still receives a\n // valid plugin object; it just does nothing.\n return NOOP_PLUGIN\n }\n probe.close()\n\n const sourceId = makeSourceId()\n let msgIdCounter = 0\n const seenByPeer = new Map<string, number>()\n let api: QueryClientPluginApi | null = null\n let channel: ChannelLike | null = null\n\n const listener = (event: { data: unknown }) => {\n const msg = event.data as Partial<Message> | null\n if (!msg || typeof msg !== 'object') return\n // Layer 1 — protocol version drop.\n if (msg.v !== PROTOCOL_VERSION) return\n // Layer 2 — own-source drop (transport echoed our own message back).\n if (msg.sourceId === sourceId) return\n // Layer 3 — out-of-order / duplicate drop.\n if (typeof msg.sourceId !== 'string' || typeof msg.msgId !== 'number') return\n const last = seenByPeer.get(msg.sourceId) ?? -1\n if (msg.msgId <= last) return\n seenByPeer.set(msg.sourceId, msg.msgId)\n\n if (msg.type === 'setData') {\n if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {\n onWarn('[olas/cross-tab] malformed setData message')\n return\n }\n api?.applyRemoteSetData(msg.queryId, msg.keyArgs, msg.data)\n return\n }\n if (msg.type === 'invalidate') {\n if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {\n onWarn('[olas/cross-tab] malformed invalidate message')\n return\n }\n api?.applyRemoteInvalidate(msg.queryId, msg.keyArgs)\n return\n }\n }\n\n const send = (msg: Message): void => {\n if (channel === null) return\n try {\n channel.postMessage(msg)\n } catch (cause) {\n // Structured clone failed — most likely non-cloneable data on a\n // setData payload. Warn and drop.\n onWarn(\n `[olas/cross-tab] failed to broadcast ${msg.type} for queryId=\"${msg.queryId}\": data is not structured-cloneable`,\n cause,\n )\n }\n }\n\n let initialized = false\n return {\n init(a) {\n // The plugin instance owns one `sourceId`, one channel, and one\n // listener Map. Sharing it across two roots would clobber that state\n // on the second `init` and leak the first channel. Construct a fresh\n // `crossTabPlugin({ ... })` per root.\n if (initialized) {\n throw new Error(\n '[olas/cross-tab] crossTabPlugin instance reused across multiple roots. ' +\n 'Each root must get its own `crossTabPlugin({ ... })`.',\n )\n }\n initialized = true\n api = a\n channel = factory(channelName) ?? null\n channel?.addEventListener('message', listener)\n },\n\n onSetData(event: SetDataEvent) {\n // Don't echo inbound writes — Layer-1 sender-side echo prevention.\n if (event.isRemote) return\n // Fetch-success writes are a per-tab concern: every tab runs its own\n // fetcher and would otherwise rebroadcast results to peers that just\n // fetched the same data themselves. We only echo explicit `setData`\n // calls (mutations, optimistic patches, entity backprop) cross-tab.\n if (event.source === 'fetch') return\n // Infinite queries are deferred for v1 — see SPEC §13.2.\n if (event.kind !== 'data') return\n if (!shouldBroadcast(event.queryId)) return\n\n send({\n v: PROTOCOL_VERSION,\n type: 'setData',\n sourceId,\n msgId: ++msgIdCounter,\n queryId: event.queryId,\n keyArgs: event.keyArgs,\n data: event.data,\n })\n },\n\n onInvalidate(event: InvalidateEvent) {\n if (event.isRemote) return\n if (event.kind !== 'data') return\n if (!shouldBroadcast(event.queryId)) return\n\n send({\n v: PROTOCOL_VERSION,\n type: 'invalidate',\n sourceId,\n msgId: ++msgIdCounter,\n queryId: event.queryId,\n keyArgs: event.keyArgs,\n })\n },\n\n onGc(_event: GcEvent) {\n // GC is local — we don't propagate it. Each tab gc's its own entries\n // when its own subscribers drop.\n },\n\n dispose() {\n if (channel !== null) {\n channel.removeEventListener('message', listener)\n channel.close()\n channel = null\n }\n api = null\n // Allow re-`init` only if a future runtime explicitly reattaches; today\n // `QueryClient.dispose` is final, so this is mostly defensive.\n initialized = false\n },\n }\n}\n\nfunction defaultWarn(message: string, cause?: unknown): void {\n if (cause !== undefined) {\n console.warn(message, cause)\n } else {\n console.warn(message)\n }\n}\n\n/**\n * Per-query gate. `crossTab: true` is a static opt-in on the spec; the\n * QueryClient doesn't filter on it (its events fire for every query that\n * has a `queryId`), so the plugin checks here. We look it up from the\n * core's query registry on every event — no caching, since the registry\n * lookup is a Map.get.\n */\nfunction shouldBroadcast(queryId: string): boolean {\n const registered = lookupRegisteredQuery(queryId)\n if (!registered) return false\n return registered.__spec.crossTab === true\n}\n"],"mappings":"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"}
package/dist/index.mjs CHANGED
@@ -130,14 +130,18 @@ function crossTabPlugin(options) {
130
130
  onWarn(`[olas/cross-tab] failed to broadcast ${msg.type} for queryId="${msg.queryId}": data is not structured-cloneable`, cause);
131
131
  }
132
132
  };
133
+ let initialized = false;
133
134
  return {
134
135
  init(a) {
136
+ if (initialized) throw new Error("[olas/cross-tab] crossTabPlugin instance reused across multiple roots. Each root must get its own `crossTabPlugin({ ... })`.");
137
+ initialized = true;
135
138
  api = a;
136
139
  channel = factory(channelName) ?? null;
137
140
  channel?.addEventListener("message", listener);
138
141
  },
139
142
  onSetData(event) {
140
143
  if (event.isRemote) return;
144
+ if (event.source === "fetch") return;
141
145
  if (event.kind !== "data") return;
142
146
  if (!shouldBroadcast(event.queryId)) return;
143
147
  send({
@@ -171,6 +175,7 @@ function crossTabPlugin(options) {
171
175
  channel = null;
172
176
  }
173
177
  api = null;
178
+ initialized = false;
174
179
  }
175
180
  };
176
181
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/channel.ts","../src/protocol.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Tiny `BroadcastChannel`-shaped abstraction. Lets tests inject a fake\n * (a shared in-memory bus across multiple \"tabs\" in the same process) and\n * keeps SSR-safety in one place — when `BroadcastChannel` is absent and\n * no `channelFactory` override is supplied, the plugin returns a no-op\n * variant up the stack.\n */\n\nexport type ChannelLike = {\n postMessage(data: unknown): void\n addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void\n removeEventListener(type: 'message', listener: (event: { data: unknown }) => void): void\n close(): void\n}\n\n/**\n * Default factory: wraps the platform `BroadcastChannel`. Returns\n * `undefined` when `BroadcastChannel` is not defined (SSR / Node without\n * `--experimental-broadcastchannel`, older browsers).\n */\nexport function defaultChannelFactory(name: string): ChannelLike | undefined {\n if (typeof BroadcastChannel === 'undefined') return undefined\n const ch = new BroadcastChannel(name)\n return {\n postMessage(data) {\n ch.postMessage(data)\n },\n addEventListener(type, listener) {\n // The platform BroadcastChannel typing wants a `MessageEvent`\n // listener, but we only care about `event.data` — cast through\n // `unknown` since the shapes don't structurally overlap.\n ch.addEventListener(type, listener as unknown as EventListener)\n },\n removeEventListener(type, listener) {\n ch.removeEventListener(type, listener as unknown as EventListener)\n },\n close() {\n ch.close()\n },\n }\n}\n","/**\n * Wire protocol for `@kontsedal/olas-cross-tab` messages. SPEC §13.2.\n *\n * `v` (protocol version) and `sourceId` (per-plugin-instance unique tag)\n * combine to make the three-layer echo prevention work:\n *\n * 1. Sender skips broadcast when `SetDataEvent.isRemote === true` (the\n * write originated from `applyRemoteSetData`).\n * 2. Receiver filters its own `sourceId` (catches the case where the\n * transport echoes the message back to the sender).\n * 3. Receiver dedupes by `(sourceId, msgId)` — duplicate or out-of-order\n * messages from the same peer are dropped.\n *\n * Receivers also drop messages with a `v` they don't understand. The\n * channel name itself is user-supplied; consumers who want clean\n * cross-deploy isolation should embed a version in their `channelName`.\n */\n\nexport const PROTOCOL_VERSION = 1\n\nexport type SetDataMessage = {\n v: typeof PROTOCOL_VERSION\n type: 'setData'\n sourceId: string\n msgId: number\n queryId: string\n keyArgs: readonly unknown[]\n data: unknown\n}\n\nexport type InvalidateMessage = {\n v: typeof PROTOCOL_VERSION\n type: 'invalidate'\n sourceId: string\n msgId: number\n queryId: string\n keyArgs: readonly unknown[]\n}\n\nexport type Message = SetDataMessage | InvalidateMessage\n","import {\n type GcEvent,\n type InvalidateEvent,\n lookupRegisteredQuery,\n type QueryClientPlugin,\n type QueryClientPluginApi,\n type SetDataEvent,\n} from '@kontsedal/olas-core'\nimport { type ChannelLike, defaultChannelFactory } from './channel'\nimport { type Message, PROTOCOL_VERSION } from './protocol'\n\n/**\n * Options accepted by `crossTabPlugin(...)`. SPEC §13.2.\n *\n * - `channelName` — name of the `BroadcastChannel`. Required. Users who\n * want clean cross-deploy isolation should include a version suffix\n * (e.g. `'my-app/cache/v2'`).\n * - `onWarn` — called for non-fatal conditions: a `DataCloneError` while\n * posting (the data isn't structured-cloneable), or a malformed\n * inbound message. Default: `console.warn`.\n * - `channelFactory` — override the channel constructor. Mainly for\n * tests that share an in-memory bus across two QueryClients.\n */\nexport type CrossTabOptions = {\n channelName: string\n onWarn?: (message: string, cause?: unknown) => void\n channelFactory?: (name: string) => ChannelLike | undefined\n}\n\n/**\n * Generate a unique-enough source id for a plugin instance. Combines\n * `Date.now()` with `Math.random()` — collisions across same-millisecond\n * tab-opens are negligible at one-decimal-place randomness, and even a\n * collision only loses dedup, not correctness.\n */\nfunction makeSourceId(): string {\n const rand = Math.random().toString(36).slice(2, 10)\n return `${Date.now().toString(36)}-${rand}`\n}\n\nconst NOOP_PLUGIN: QueryClientPlugin = {}\n\n/**\n * Cross-tab cache sync over `BroadcastChannel`. Mirrors `setData` /\n * `invalidate` writes across tabs of the same origin.\n *\n * Wire it up via `RootOptions.plugins`:\n *\n * ```ts\n * createRoot(appController, {\n * deps,\n * plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],\n * })\n * ```\n *\n * Queries must opt in via `defineQuery({ queryId: '<unique>', crossTab: true })`\n * — the `queryId` routes inbound messages back to the right query, and\n * `crossTab: true` is the per-query opt-in (queries that don't set it are\n * ignored by the sender so module-internal queries don't leak).\n *\n * **SSR safety.** When `BroadcastChannel` is not defined (Node, older\n * browsers without the feature) and no `channelFactory` override is\n * supplied, the function returns a no-op plugin object. The root still\n * boots cleanly; cross-tab is just disabled.\n *\n * **Non-cloneable data.** `BroadcastChannel` uses structured clone. Cache\n * data containing functions, class instances, or symbols throws a\n * `DataCloneError` at `postMessage`. The plugin catches the throw, calls\n * `onWarn(...)`, and drops the message — the sender's cache is unaffected.\n */\nexport function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {\n const channelName = options.channelName\n const onWarn = options.onWarn ?? defaultWarn\n const factory = options.channelFactory ?? defaultChannelFactory\n\n // Cheap probe — if the environment can't produce a channel at all we can\n // return a no-op plugin without opening anything. The real channel opens\n // lazily in `init` so a plugin that's constructed but never passed to a\n // root doesn't leak a BroadcastChannel.\n const probe = factory(channelName)\n if (!probe) {\n // SSR / unsupported environment. Caller's plugin slot still receives a\n // valid plugin object; it just does nothing.\n return NOOP_PLUGIN\n }\n probe.close()\n\n const sourceId = makeSourceId()\n let msgIdCounter = 0\n const seenByPeer = new Map<string, number>()\n let api: QueryClientPluginApi | null = null\n let channel: ChannelLike | null = null\n\n const listener = (event: { data: unknown }) => {\n const msg = event.data as Partial<Message> | null\n if (!msg || typeof msg !== 'object') return\n // Layer 1 — protocol version drop.\n if (msg.v !== PROTOCOL_VERSION) return\n // Layer 2 — own-source drop (transport echoed our own message back).\n if (msg.sourceId === sourceId) return\n // Layer 3 — out-of-order / duplicate drop.\n if (typeof msg.sourceId !== 'string' || typeof msg.msgId !== 'number') return\n const last = seenByPeer.get(msg.sourceId) ?? -1\n if (msg.msgId <= last) return\n seenByPeer.set(msg.sourceId, msg.msgId)\n\n if (msg.type === 'setData') {\n if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {\n onWarn('[olas/cross-tab] malformed setData message')\n return\n }\n api?.applyRemoteSetData(msg.queryId, msg.keyArgs, msg.data)\n return\n }\n if (msg.type === 'invalidate') {\n if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {\n onWarn('[olas/cross-tab] malformed invalidate message')\n return\n }\n api?.applyRemoteInvalidate(msg.queryId, msg.keyArgs)\n return\n }\n }\n\n const send = (msg: Message): void => {\n if (channel === null) return\n try {\n channel.postMessage(msg)\n } catch (cause) {\n // Structured clone failed — most likely non-cloneable data on a\n // setData payload. Warn and drop.\n onWarn(\n `[olas/cross-tab] failed to broadcast ${msg.type} for queryId=\"${msg.queryId}\": data is not structured-cloneable`,\n cause,\n )\n }\n }\n\n return {\n init(a) {\n api = a\n channel = factory(channelName) ?? null\n channel?.addEventListener('message', listener)\n },\n\n onSetData(event: SetDataEvent) {\n // Don't echo inbound writes — Layer-1 sender-side echo prevention.\n if (event.isRemote) return\n // Infinite queries are deferred for v1 — see SPEC §13.2.\n if (event.kind !== 'data') return\n if (!shouldBroadcast(event.queryId)) return\n\n send({\n v: PROTOCOL_VERSION,\n type: 'setData',\n sourceId,\n msgId: ++msgIdCounter,\n queryId: event.queryId,\n keyArgs: event.keyArgs,\n data: event.data,\n })\n },\n\n onInvalidate(event: InvalidateEvent) {\n if (event.isRemote) return\n if (event.kind !== 'data') return\n if (!shouldBroadcast(event.queryId)) return\n\n send({\n v: PROTOCOL_VERSION,\n type: 'invalidate',\n sourceId,\n msgId: ++msgIdCounter,\n queryId: event.queryId,\n keyArgs: event.keyArgs,\n })\n },\n\n onGc(_event: GcEvent) {\n // GC is local — we don't propagate it. Each tab gc's its own entries\n // when its own subscribers drop.\n },\n\n dispose() {\n if (channel !== null) {\n channel.removeEventListener('message', listener)\n channel.close()\n channel = null\n }\n api = null\n },\n }\n}\n\nfunction defaultWarn(message: string, cause?: unknown): void {\n if (cause !== undefined) {\n console.warn(message, cause)\n } else {\n console.warn(message)\n }\n}\n\n/**\n * Per-query gate. `crossTab: true` is a static opt-in on the spec; the\n * QueryClient doesn't filter on it (its events fire for every query that\n * has a `queryId`), so the plugin checks here. We look it up from the\n * core's query registry on every event — no caching, since the registry\n * lookup is a Map.get.\n */\nfunction shouldBroadcast(queryId: string): boolean {\n const registered = lookupRegisteredQuery(queryId)\n if (!registered) return false\n return registered.__spec.crossTab === true\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/channel.ts","../src/protocol.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Tiny `BroadcastChannel`-shaped abstraction. Lets tests inject a fake\n * (a shared in-memory bus across multiple \"tabs\" in the same process) and\n * keeps SSR-safety in one place — when `BroadcastChannel` is absent and\n * no `channelFactory` override is supplied, the plugin returns a no-op\n * variant up the stack.\n */\n\nexport type ChannelLike = {\n postMessage(data: unknown): void\n addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void\n removeEventListener(type: 'message', listener: (event: { data: unknown }) => void): void\n close(): void\n}\n\n/**\n * Default factory: wraps the platform `BroadcastChannel`. Returns\n * `undefined` when `BroadcastChannel` is not defined (SSR / Node without\n * `--experimental-broadcastchannel`, older browsers).\n */\nexport function defaultChannelFactory(name: string): ChannelLike | undefined {\n if (typeof BroadcastChannel === 'undefined') return undefined\n const ch = new BroadcastChannel(name)\n return {\n postMessage(data) {\n ch.postMessage(data)\n },\n addEventListener(type, listener) {\n // The platform BroadcastChannel typing wants a `MessageEvent`\n // listener, but we only care about `event.data` — cast through\n // `unknown` since the shapes don't structurally overlap.\n ch.addEventListener(type, listener as unknown as EventListener)\n },\n removeEventListener(type, listener) {\n ch.removeEventListener(type, listener as unknown as EventListener)\n },\n close() {\n ch.close()\n },\n }\n}\n","/**\n * Wire protocol for `@kontsedal/olas-cross-tab` messages. SPEC §13.2.\n *\n * `v` (protocol version) and `sourceId` (per-plugin-instance unique tag)\n * combine to make the three-layer echo prevention work:\n *\n * 1. Sender skips broadcast when `SetDataEvent.isRemote === true` (the\n * write originated from `applyRemoteSetData`).\n * 2. Receiver filters its own `sourceId` (catches the case where the\n * transport echoes the message back to the sender).\n * 3. Receiver dedupes by `(sourceId, msgId)` — duplicate or out-of-order\n * messages from the same peer are dropped.\n *\n * Receivers also drop messages with a `v` they don't understand. The\n * channel name itself is user-supplied; consumers who want clean\n * cross-deploy isolation should embed a version in their `channelName`.\n */\n\nexport const PROTOCOL_VERSION = 1\n\nexport type SetDataMessage = {\n v: typeof PROTOCOL_VERSION\n type: 'setData'\n sourceId: string\n msgId: number\n queryId: string\n keyArgs: readonly unknown[]\n data: unknown\n}\n\nexport type InvalidateMessage = {\n v: typeof PROTOCOL_VERSION\n type: 'invalidate'\n sourceId: string\n msgId: number\n queryId: string\n keyArgs: readonly unknown[]\n}\n\nexport type Message = SetDataMessage | InvalidateMessage\n","import {\n type GcEvent,\n type InvalidateEvent,\n lookupRegisteredQuery,\n type QueryClientPlugin,\n type QueryClientPluginApi,\n type SetDataEvent,\n} from '@kontsedal/olas-core'\nimport { type ChannelLike, defaultChannelFactory } from './channel'\nimport { type Message, PROTOCOL_VERSION } from './protocol'\n\n/**\n * Options accepted by `crossTabPlugin(...)`. SPEC §13.2.\n *\n * - `channelName` — name of the `BroadcastChannel`. Required. Users who\n * want clean cross-deploy isolation should include a version suffix\n * (e.g. `'my-app/cache/v2'`).\n * - `onWarn` — called for non-fatal conditions: a `DataCloneError` while\n * posting (the data isn't structured-cloneable), or a malformed\n * inbound message. Default: `console.warn`.\n * - `channelFactory` — override the channel constructor. Mainly for\n * tests that share an in-memory bus across two QueryClients.\n */\nexport type CrossTabOptions = {\n channelName: string\n onWarn?: (message: string, cause?: unknown) => void\n channelFactory?: (name: string) => ChannelLike | undefined\n}\n\n/**\n * Generate a unique-enough source id for a plugin instance. Combines\n * `Date.now()` with `Math.random()` — collisions across same-millisecond\n * tab-opens are negligible at one-decimal-place randomness, and even a\n * collision only loses dedup, not correctness.\n */\nfunction makeSourceId(): string {\n const rand = Math.random().toString(36).slice(2, 10)\n return `${Date.now().toString(36)}-${rand}`\n}\n\nconst NOOP_PLUGIN: QueryClientPlugin = {}\n\n/**\n * Cross-tab cache sync over `BroadcastChannel`. Mirrors `setData` /\n * `invalidate` writes across tabs of the same origin.\n *\n * Wire it up via `RootOptions.plugins`:\n *\n * ```ts\n * createRoot(appController, {\n * deps,\n * plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],\n * })\n * ```\n *\n * Queries must opt in via `defineQuery({ queryId: '<unique>', crossTab: true })`\n * — the `queryId` routes inbound messages back to the right query, and\n * `crossTab: true` is the per-query opt-in (queries that don't set it are\n * ignored by the sender so module-internal queries don't leak).\n *\n * **SSR safety.** When `BroadcastChannel` is not defined (Node, older\n * browsers without the feature) and no `channelFactory` override is\n * supplied, the function returns a no-op plugin object. The root still\n * boots cleanly; cross-tab is just disabled.\n *\n * **Non-cloneable data.** `BroadcastChannel` uses structured clone. Cache\n * data containing functions, class instances, or symbols throws a\n * `DataCloneError` at `postMessage`. The plugin catches the throw, calls\n * `onWarn(...)`, and drops the message — the sender's cache is unaffected.\n */\nexport function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {\n const channelName = options.channelName\n const onWarn = options.onWarn ?? defaultWarn\n const factory = options.channelFactory ?? defaultChannelFactory\n\n // Cheap probe — if the environment can't produce a channel at all we can\n // return a no-op plugin without opening anything. The real channel opens\n // lazily in `init` so a plugin that's constructed but never passed to a\n // root doesn't leak a BroadcastChannel.\n const probe = factory(channelName)\n if (!probe) {\n // SSR / unsupported environment. Caller's plugin slot still receives a\n // valid plugin object; it just does nothing.\n return NOOP_PLUGIN\n }\n probe.close()\n\n const sourceId = makeSourceId()\n let msgIdCounter = 0\n const seenByPeer = new Map<string, number>()\n let api: QueryClientPluginApi | null = null\n let channel: ChannelLike | null = null\n\n const listener = (event: { data: unknown }) => {\n const msg = event.data as Partial<Message> | null\n if (!msg || typeof msg !== 'object') return\n // Layer 1 — protocol version drop.\n if (msg.v !== PROTOCOL_VERSION) return\n // Layer 2 — own-source drop (transport echoed our own message back).\n if (msg.sourceId === sourceId) return\n // Layer 3 — out-of-order / duplicate drop.\n if (typeof msg.sourceId !== 'string' || typeof msg.msgId !== 'number') return\n const last = seenByPeer.get(msg.sourceId) ?? -1\n if (msg.msgId <= last) return\n seenByPeer.set(msg.sourceId, msg.msgId)\n\n if (msg.type === 'setData') {\n if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {\n onWarn('[olas/cross-tab] malformed setData message')\n return\n }\n api?.applyRemoteSetData(msg.queryId, msg.keyArgs, msg.data)\n return\n }\n if (msg.type === 'invalidate') {\n if (typeof msg.queryId !== 'string' || !Array.isArray(msg.keyArgs)) {\n onWarn('[olas/cross-tab] malformed invalidate message')\n return\n }\n api?.applyRemoteInvalidate(msg.queryId, msg.keyArgs)\n return\n }\n }\n\n const send = (msg: Message): void => {\n if (channel === null) return\n try {\n channel.postMessage(msg)\n } catch (cause) {\n // Structured clone failed — most likely non-cloneable data on a\n // setData payload. Warn and drop.\n onWarn(\n `[olas/cross-tab] failed to broadcast ${msg.type} for queryId=\"${msg.queryId}\": data is not structured-cloneable`,\n cause,\n )\n }\n }\n\n let initialized = false\n return {\n init(a) {\n // The plugin instance owns one `sourceId`, one channel, and one\n // listener Map. Sharing it across two roots would clobber that state\n // on the second `init` and leak the first channel. Construct a fresh\n // `crossTabPlugin({ ... })` per root.\n if (initialized) {\n throw new Error(\n '[olas/cross-tab] crossTabPlugin instance reused across multiple roots. ' +\n 'Each root must get its own `crossTabPlugin({ ... })`.',\n )\n }\n initialized = true\n api = a\n channel = factory(channelName) ?? null\n channel?.addEventListener('message', listener)\n },\n\n onSetData(event: SetDataEvent) {\n // Don't echo inbound writes — Layer-1 sender-side echo prevention.\n if (event.isRemote) return\n // Fetch-success writes are a per-tab concern: every tab runs its own\n // fetcher and would otherwise rebroadcast results to peers that just\n // fetched the same data themselves. We only echo explicit `setData`\n // calls (mutations, optimistic patches, entity backprop) cross-tab.\n if (event.source === 'fetch') return\n // Infinite queries are deferred for v1 — see SPEC §13.2.\n if (event.kind !== 'data') return\n if (!shouldBroadcast(event.queryId)) return\n\n send({\n v: PROTOCOL_VERSION,\n type: 'setData',\n sourceId,\n msgId: ++msgIdCounter,\n queryId: event.queryId,\n keyArgs: event.keyArgs,\n data: event.data,\n })\n },\n\n onInvalidate(event: InvalidateEvent) {\n if (event.isRemote) return\n if (event.kind !== 'data') return\n if (!shouldBroadcast(event.queryId)) return\n\n send({\n v: PROTOCOL_VERSION,\n type: 'invalidate',\n sourceId,\n msgId: ++msgIdCounter,\n queryId: event.queryId,\n keyArgs: event.keyArgs,\n })\n },\n\n onGc(_event: GcEvent) {\n // GC is local — we don't propagate it. Each tab gc's its own entries\n // when its own subscribers drop.\n },\n\n dispose() {\n if (channel !== null) {\n channel.removeEventListener('message', listener)\n channel.close()\n channel = null\n }\n api = null\n // Allow re-`init` only if a future runtime explicitly reattaches; today\n // `QueryClient.dispose` is final, so this is mostly defensive.\n initialized = false\n },\n }\n}\n\nfunction defaultWarn(message: string, cause?: unknown): void {\n if (cause !== undefined) {\n console.warn(message, cause)\n } else {\n console.warn(message)\n }\n}\n\n/**\n * Per-query gate. `crossTab: true` is a static opt-in on the spec; the\n * QueryClient doesn't filter on it (its events fire for every query that\n * has a `queryId`), so the plugin checks here. We look it up from the\n * core's query registry on every event — no caching, since the registry\n * lookup is a Map.get.\n */\nfunction shouldBroadcast(queryId: string): boolean {\n const registered = lookupRegisteredQuery(queryId)\n if (!registered) return false\n return registered.__spec.crossTab === true\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontsedal/olas-cross-tab",
3
- "version": "0.0.1-rc.1",
3
+ "version": "0.0.1",
4
4
  "description": "Olas cross-tab cache sync — BroadcastChannel-backed QueryClientPlugin keeping every browser tab in lockstep.",
5
5
  "keywords": [
6
6
  "olas",
@@ -45,10 +45,10 @@
45
45
  ],
46
46
  "sideEffects": false,
47
47
  "peerDependencies": {
48
- "@kontsedal/olas-core": "^0.0.1-rc.1"
48
+ "@kontsedal/olas-core": "^0.0.1"
49
49
  },
50
50
  "devDependencies": {
51
- "@kontsedal/olas-core": "^0.0.1-rc.1"
51
+ "@kontsedal/olas-core": "^0.0.1"
52
52
  },
53
53
  "publishConfig": {
54
54
  "access": "public"
package/src/plugin.ts CHANGED
@@ -136,8 +136,20 @@ export function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {
136
136
  }
137
137
  }
138
138
 
139
+ let initialized = false
139
140
  return {
140
141
  init(a) {
142
+ // The plugin instance owns one `sourceId`, one channel, and one
143
+ // listener Map. Sharing it across two roots would clobber that state
144
+ // on the second `init` and leak the first channel. Construct a fresh
145
+ // `crossTabPlugin({ ... })` per root.
146
+ if (initialized) {
147
+ throw new Error(
148
+ '[olas/cross-tab] crossTabPlugin instance reused across multiple roots. ' +
149
+ 'Each root must get its own `crossTabPlugin({ ... })`.',
150
+ )
151
+ }
152
+ initialized = true
141
153
  api = a
142
154
  channel = factory(channelName) ?? null
143
155
  channel?.addEventListener('message', listener)
@@ -146,6 +158,11 @@ export function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {
146
158
  onSetData(event: SetDataEvent) {
147
159
  // Don't echo inbound writes — Layer-1 sender-side echo prevention.
148
160
  if (event.isRemote) return
161
+ // Fetch-success writes are a per-tab concern: every tab runs its own
162
+ // fetcher and would otherwise rebroadcast results to peers that just
163
+ // fetched the same data themselves. We only echo explicit `setData`
164
+ // calls (mutations, optimistic patches, entity backprop) cross-tab.
165
+ if (event.source === 'fetch') return
149
166
  // Infinite queries are deferred for v1 — see SPEC §13.2.
150
167
  if (event.kind !== 'data') return
151
168
  if (!shouldBroadcast(event.queryId)) return
@@ -188,6 +205,9 @@ export function crossTabPlugin(options: CrossTabOptions): QueryClientPlugin {
188
205
  channel = null
189
206
  }
190
207
  api = null
208
+ // Allow re-`init` only if a future runtime explicitly reattaches; today
209
+ // `QueryClient.dispose` is final, so this is mostly defensive.
210
+ initialized = false
191
211
  },
192
212
  }
193
213
  }