import { BroadcastChannel } from 'broadcast-channel' import type { BroadcastChannelOptions } from 'broadcast-channel' import type { QueryClient, QueryKey } from '@tanstack/query-core' /** * Metadata describing a broadcast that failed to be delivered to other tabs. * Passed to {@link BroadcastQueryClientOptions.onBroadcastError} so callers * can correlate failures with the originating query. */ export interface BroadcastErrorEvent { type: 'updated' | 'removed' | 'added' queryHash: string queryKey: QueryKey } type BroadcastMessage = | { type: 'updated'; queryHash: string; queryKey: QueryKey; state: unknown } | { type: 'removed'; queryHash: string; queryKey: QueryKey } | { type: 'added'; queryHash: string; queryKey: QueryKey; state: unknown } interface BroadcastQueryClientOptions { /** The QueryClient to sync. */ queryClient: QueryClient /** * Unique channel name used to communicate between tabs and windows. * @default 'tanstack-query' */ broadcastChannel?: string /** Options forwarded to the underlying `BroadcastChannel`. */ options?: BroadcastChannelOptions /** * Called when a query event fails to broadcast to other tabs — most * commonly because the query's `state.data`, `state.error`, or `queryKey` * contains a value the structured-clone algorithm cannot serialize * (e.g. `ReadableStream`, `File`, functions, Vue `reactive` proxies). * * Provide this to route failures to an error tracker. If omitted, a * `console.warn` is emitted in development so failures are never silent. * * May return a `Promise`; any rejection is caught internally so it cannot * cause a secondary unhandled rejection. */ onBroadcastError?: ( error: unknown, event: BroadcastErrorEvent, ) => void | Promise } export function broadcastQueryClient({ queryClient, broadcastChannel = 'tanstack-query', options, onBroadcastError, }: BroadcastQueryClientOptions): () => void { let transaction = false const tx = (cb: () => void) => { transaction = true try { cb() } finally { // Guard against `cb` throwing (e.g. `query.setState`/`queryCache.build` // triggering a listener that throws while applying an incoming // cross-tab message). Without this, `transaction` would stay `true` // forever, silently disabling this tab's own broadcasts to other tabs // for the rest of the session. transaction = false } } const channel = new BroadcastChannel(broadcastChannel, { webWorkerSupport: false, ...options, }) const queryCache = queryClient.getQueryCache() const safePost = (message: BroadcastMessage): void => { channel.postMessage(message).catch((error: unknown) => { const event: BroadcastErrorEvent = { type: message.type, queryHash: message.queryHash, queryKey: message.queryKey, } if (onBroadcastError) { const warnCallbackError = (callbackError: unknown) => { if (process.env.NODE_ENV !== 'production') { console.warn( `[broadcastQueryClient] onBroadcastError threw while handling "${event.type}" for query ${event.queryHash}.`, callbackError, ) } } let result: void | Promise try { result = onBroadcastError(error, event) } catch (callbackError) { warnCallbackError(callbackError) return } result?.catch(warnCallbackError) } else if (process.env.NODE_ENV !== 'production') { console.warn( `[broadcastQueryClient] Failed to broadcast "${event.type}" event for query ${event.queryHash}. ` + 'The query value could not be structured-cloned; cross-tab sync for this query was skipped.', error, ) } }) } const unsubscribe = queryCache.subscribe((queryEvent) => { if (transaction) { return } const { query: { queryHash, queryKey, state, observers }, } = queryEvent if (queryEvent.type === 'updated' && queryEvent.action.type === 'success') { safePost({ type: 'updated', queryHash, queryKey, state, }) } if (queryEvent.type === 'removed' && observers.length > 0) { safePost({ type: 'removed', queryHash, queryKey, }) } if (queryEvent.type === 'added') { safePost({ type: 'added', queryHash, queryKey, state, }) } }) channel.onmessage = (action) => { if (!action?.type) { return } tx(() => { const { type, queryHash, queryKey, state } = action const query = queryCache.get(queryHash) if (type === 'updated') { if (query) { query.setState(state) return } queryCache.build( queryClient, { queryKey, queryHash, }, state, ) } else if (type === 'removed') { if (query) { queryCache.remove(query) } } else if (type === 'added') { if (query) { query.setState(state) return } queryCache.build( queryClient, { queryKey, queryHash, }, state, ) } }) } return () => { unsubscribe() channel.close() } }