Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-broadcast-client-postmessage.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions docs/framework/react/plugins/broadcastQueryClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```

Expand All @@ -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 },
})
},
})
```
Original file line number Diff line number Diff line change
@@ -1,15 +1,43 @@
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<void>>(),
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

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', () => {
Expand All @@ -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)
}
})
})
})
98 changes: 80 additions & 18 deletions packages/query-broadcast-client-experimental/src/index.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -27,7 +62,47 @@ export function broadcastQueryClient({

const queryCache = queryClient.getQueryCache()

const unsubscribe = queryClient.getQueryCache().subscribe((queryEvent) => {
// `broadcast-channel`'s `postMessage` returns a `Promise<void>` 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,
)
}
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const unsubscribe = queryCache.subscribe((queryEvent) => {
if (transaction) {
return
}
Expand All @@ -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 })
}
})

Expand Down