diff --git a/.changeset/fix-broadcast-client-postmessage.md b/.changeset/fix-broadcast-client-postmessage.md new file mode 100644 index 00000000000..3bcad051699 --- /dev/null +++ b/.changeset/fix-broadcast-client-postmessage.md @@ -0,0 +1,7 @@ +--- +'@tanstack/query-broadcast-client-experimental': patch +--- + +fix(query-broadcast-client-experimental): stop leaking `postMessage` rejections as unhandled errors + +`BroadcastChannel.postMessage` rejects when a query payload cannot be structured-cloned (e.g. `ReadableStream`, `File`, functions, Vue `reactive` / MobX proxies). Those rejections are now handled internally and surfaced through an optional `onBroadcastError` hook; when the hook is not provided, a development-only `console.warn` reports the offending query so cross-tab sync failures are never silent. diff --git a/docs/framework/react/plugins/broadcastQueryClient.md b/docs/framework/react/plugins/broadcastQueryClient.md index 46b0306c924..bccd4b64c00 100644 --- a/docs/framework/react/plugins/broadcastQueryClient.md +++ b/docs/framework/react/plugins/broadcastQueryClient.md @@ -49,6 +49,18 @@ interface BroadcastQueryClientOptions { broadcastChannel?: string /** Options for the BroadcastChannel API */ options?: BroadcastChannelOptions + /** Called when a query event cannot be 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, framework + * proxies). Useful for routing failures to an error tracker. */ + onBroadcastError?: (error: unknown, event: BroadcastErrorEvent) => void +} + +interface BroadcastErrorEvent { + type: 'added' | 'removed' | 'updated' + queryHash: string + queryKey: QueryKey } ``` @@ -59,3 +71,24 @@ The default options are: broadcastChannel = 'tanstack-query', } ``` + +### Handling broadcast errors + +If your cache can hold values that are not structured-cloneable — such as `ReadableStream` (often coming from `Response.body` or streaming APIs), `File`, functions, or framework proxies like Vue `reactive` — the underlying `BroadcastChannel.postMessage` call will reject for that query. Cross-tab sync is skipped for that query; the rest of the cache continues to broadcast normally. + +By default, a `console.warn` is emitted in development so failures are never silent. Provide `onBroadcastError` to route the failure to your own error tracker: + +```tsx +import * as Sentry from '@sentry/browser' + +broadcastQueryClient({ + queryClient, + broadcastChannel: 'my-app', + onBroadcastError: (error, event) => { + Sentry.captureException(error, { + tags: { broadcastEvent: event.type }, + extra: { queryHash: event.queryHash, queryKey: event.queryKey }, + }) + }, +}) +``` diff --git a/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts b/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts index 6e09d8a86e2..8a4b5e0bad1 100644 --- a/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts +++ b/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts @@ -1,8 +1,30 @@ import { QueryClient } from '@tanstack/query-core' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { broadcastQueryClient } from '..' +import type { BroadcastErrorEvent } from '..' import type { QueryCache } from '@tanstack/query-core' +// Mock `broadcast-channel` so tests can drive the `postMessage` promise +// deterministically - jsdom's own `BroadcastChannel` support varies and we +// need to force the failure path that the fix guards against. +const { channelMock, MockBroadcastChannel } = vi.hoisted(() => { + const channel = { + postMessage: vi.fn<(message: unknown) => Promise>(), + close: vi.fn<() => void>(), + onmessage: null as ((ev: unknown) => void) | null, + } + class FakeChannel { + constructor() { + return channel + } + } + return { channelMock: channel, MockBroadcastChannel: FakeChannel } +}) + +vi.mock('broadcast-channel', () => ({ + BroadcastChannel: MockBroadcastChannel, +})) + describe('broadcastQueryClient', () => { let queryClient: QueryClient let queryCache: QueryCache @@ -10,6 +32,12 @@ describe('broadcastQueryClient', () => { beforeEach(() => { queryClient = new QueryClient() queryCache = queryClient.getQueryCache() + + // `restoreMocks: true` (vite.config.ts) clears mock state between tests, + // so restore the default behavior for the shared channel mock. + channelMock.postMessage.mockReset().mockResolvedValue(undefined) + channelMock.close.mockReset() + channelMock.onmessage = null }) it('should subscribe to the query cache', () => { @@ -28,4 +56,125 @@ describe('broadcastQueryClient', () => { unsubscribe() expect(queryCache.hasListeners()).toBe(false) }) + + describe('when postMessage rejects (non-cloneable payload)', () => { + it('routes the failure to onBroadcastError with the originating query metadata', async () => { + const cloneError = new DOMException( + 'A ReadableStream could not be cloned because it was not transferred.', + 'DataCloneError', + ) + channelMock.postMessage.mockRejectedValue(cloneError) + + const errors: Array<{ error: unknown; event: BroadcastErrorEvent }> = [] + broadcastQueryClient({ + queryClient, + broadcastChannel: 'test_channel', + onBroadcastError: (error, event) => { + errors.push({ error, event }) + }, + }) + + // `setQueryData` on a fresh query triggers both `added` (from the + // build step) and `updated` (from the success dispatch). Both + // broadcasts should fail and both failures should reach the hook. + queryClient.setQueryData(['stream'], { body: 'non-cloneable' }) + + await vi.waitFor(() => { + expect(errors).toHaveLength(2) + }) + + const eventTypes = errors.map(({ event }) => event.type) + expect(eventTypes).toContain('added') + expect(eventTypes).toContain('updated') + + for (const { error, event } of errors) { + expect(error).toBe(cloneError) + expect(event.queryKey).toEqual(['stream']) + expect(event.queryHash).toEqual(expect.any(String)) + } + }) + + it('falls back to console.warn in development when no hook is provided', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + channelMock.postMessage.mockRejectedValue( + new DOMException('clone failed', 'DataCloneError'), + ) + + broadcastQueryClient({ + queryClient, + broadcastChannel: 'test_channel', + }) + + queryClient.setQueryData(['stream'], { body: 'non-cloneable' }) + + await vi.waitFor(() => { + expect(warn).toHaveBeenCalled() + }) + + const [firstCall] = warn.mock.calls + expect(firstCall?.[0]).toEqual( + expect.stringContaining('[broadcastQueryClient]'), + ) + }) + + it('does not surface broadcast failures as unhandled rejections', async () => { + const onUnhandled = vi.fn() + process.on('unhandledRejection', onUnhandled) + + try { + channelMock.postMessage.mockRejectedValue( + new DOMException('clone failed', 'DataCloneError'), + ) + + broadcastQueryClient({ + queryClient, + broadcastChannel: 'test_channel', + // Silent hook - the assertion is on the rejection path, not on the + // hook's observability side effect. + onBroadcastError: () => {}, + }) + + queryClient.setQueryData(['stream'], { body: 'non-cloneable' }) + + // Let Node's microtask queue and `unhandledRejection` scheduler run. + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(onUnhandled).not.toHaveBeenCalled() + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + it('does not surface failures even when onBroadcastError itself throws', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const onUnhandled = vi.fn() + process.on('unhandledRejection', onUnhandled) + + try { + channelMock.postMessage.mockRejectedValue( + new DOMException('clone failed', 'DataCloneError'), + ) + + broadcastQueryClient({ + queryClient, + broadcastChannel: 'test_channel', + // A misbehaving hook (e.g. a Sentry wrapper that throws during setup, + // or an inadvertent property access on a framework proxy) must not + // re-introduce an unhandled rejection through the catch handler. + onBroadcastError: () => { + throw new Error('boom') + }, + }) + + queryClient.setQueryData(['stream'], { body: 'non-cloneable' }) + + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(onUnhandled).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalled() + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + }) }) diff --git a/packages/query-broadcast-client-experimental/src/index.ts b/packages/query-broadcast-client-experimental/src/index.ts index e102b3c0b01..5c72d49383b 100644 --- a/packages/query-broadcast-client-experimental/src/index.ts +++ b/packages/query-broadcast-client-experimental/src/index.ts @@ -1,17 +1,52 @@ import { BroadcastChannel } from 'broadcast-channel' import type { BroadcastChannelOptions } from 'broadcast-channel' -import type { QueryClient } from '@tanstack/query-core' +import type { QueryClient, QueryKey } from '@tanstack/query-core' + +/** + * Metadata describing a broadcast message that could not be delivered to + * other tabs. Passed to {@link BroadcastQueryClientOptions.onBroadcastError} + * so callers can correlate failures with the originating query. + */ +export interface BroadcastErrorEvent { + type: 'added' | 'removed' | 'updated' + queryHash: string + queryKey: QueryKey +} 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 when 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 hook to route failures to an error tracker without + * producing unhandled promise rejections. If omitted, a `console.warn` + * is emitted in development so cross-tab sync failures are never + * entirely silent. + */ + onBroadcastError?: (error: unknown, event: BroadcastErrorEvent) => void } +type BroadcastMessage = + | { type: 'added'; queryHash: string; queryKey: QueryKey } + | { type: 'removed'; queryHash: string; queryKey: QueryKey } + | { type: 'updated'; queryHash: string; queryKey: QueryKey; state: unknown } + export function broadcastQueryClient({ queryClient, broadcastChannel = 'tanstack-query', options, + onBroadcastError, }: BroadcastQueryClientOptions): () => void { let transaction = false const tx = (cb: () => void) => { @@ -27,7 +62,47 @@ export function broadcastQueryClient({ const queryCache = queryClient.getQueryCache() - const unsubscribe = queryClient.getQueryCache().subscribe((queryEvent) => { + // `broadcast-channel`'s `postMessage` returns a `Promise` that rejects + // when the payload cannot be structured-cloned. Attach a catch handler so + // the rejection never escapes as an `unhandledrejection`, and surface the + // offending query through the user's hook or a development-only warning. + 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) { + // A throwing user handler would turn this `.catch` into a fresh + // rejected promise and re-introduce the exact `unhandledrejection` + // this helper exists to prevent. Guard the user-land call so the + // guarantee holds even when the hook misbehaves. + try { + onBroadcastError(error, event) + } catch (hookError) { + if (process.env.NODE_ENV !== 'production') { + console.warn( + `[broadcastQueryClient] onBroadcastError threw while handling "${event.type}" for query ${event.queryHash}.`, + hookError, + ) + } + } + return + } + + 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 } @@ -37,28 +112,15 @@ export function broadcastQueryClient({ } = queryEvent if (queryEvent.type === 'updated' && queryEvent.action.type === 'success') { - channel.postMessage({ - type: 'updated', - queryHash, - queryKey, - state, - }) + safePost({ type: 'updated', queryHash, queryKey, state }) } if (queryEvent.type === 'removed' && observers.length > 0) { - channel.postMessage({ - type: 'removed', - queryHash, - queryKey, - }) + safePost({ type: 'removed', queryHash, queryKey }) } if (queryEvent.type === 'added') { - channel.postMessage({ - type: 'added', - queryHash, - queryKey, - }) + safePost({ type: 'added', queryHash, queryKey }) } })