Skip to content

Commit 2883756

Browse files
committed
fix(query-broadcast-client-experimental): stop leaking postMessage rejections as unhandled errors
1 parent 2f9527e commit 2883756

4 files changed

Lines changed: 226 additions & 19 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/query-broadcast-client-experimental': patch
3+
---
4+
5+
fix(query-broadcast-client-experimental): stop leaking `postMessage` rejections as unhandled errors
6+
7+
`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.

docs/framework/react/plugins/broadcastQueryClient.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ interface BroadcastQueryClientOptions {
4949
broadcastChannel?: string
5050
/** Options for the BroadcastChannel API */
5151
options?: BroadcastChannelOptions
52+
/** Called when a query event cannot be broadcast to other tabs —
53+
* most commonly because the query's `state.data`, `state.error`, or
54+
* `queryKey` contains a value the structured-clone algorithm cannot
55+
* serialize (e.g. `ReadableStream`, `File`, functions, framework
56+
* proxies). Useful for routing failures to an error tracker. */
57+
onBroadcastError?: (
58+
error: unknown,
59+
event: {
60+
type: 'added' | 'removed' | 'updated'
61+
queryHash: string
62+
queryKey: QueryKey
63+
},
64+
) => void
5265
}
5366
```
5467

@@ -59,3 +72,24 @@ The default options are:
5972
broadcastChannel = 'tanstack-query',
6073
}
6174
```
75+
76+
### Handling broadcast errors
77+
78+
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.
79+
80+
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:
81+
82+
```tsx
83+
import * as Sentry from '@sentry/browser'
84+
85+
broadcastQueryClient({
86+
queryClient,
87+
broadcastChannel: 'my-app',
88+
onBroadcastError: (error, event) => {
89+
Sentry.captureException(error, {
90+
tags: { broadcastEvent: event.type },
91+
extra: { queryHash: event.queryHash, queryKey: event.queryKey },
92+
})
93+
},
94+
})
95+
```

packages/query-broadcast-client-experimental/src/__tests__/index.test.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,43 @@
11
import { QueryClient } from '@tanstack/query-core'
2-
import { beforeEach, describe, expect, it } from 'vitest'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
33
import { broadcastQueryClient } from '..'
4+
import type { BroadcastErrorEvent } from '..'
45
import type { QueryCache } from '@tanstack/query-core'
56

7+
// Mock `broadcast-channel` so tests can drive the `postMessage` promise
8+
// deterministically - jsdom's own `BroadcastChannel` support varies and we
9+
// need to force the failure path that the fix guards against.
10+
const { channelMock, MockBroadcastChannel } = vi.hoisted(() => {
11+
const channel = {
12+
postMessage: vi.fn<(message: unknown) => Promise<void>>(),
13+
close: vi.fn<() => void>(),
14+
onmessage: null as ((ev: unknown) => void) | null,
15+
}
16+
class FakeChannel {
17+
constructor() {
18+
return channel
19+
}
20+
}
21+
return { channelMock: channel, MockBroadcastChannel: FakeChannel }
22+
})
23+
24+
vi.mock('broadcast-channel', () => ({
25+
BroadcastChannel: MockBroadcastChannel,
26+
}))
27+
628
describe('broadcastQueryClient', () => {
729
let queryClient: QueryClient
830
let queryCache: QueryCache
931

1032
beforeEach(() => {
1133
queryClient = new QueryClient()
1234
queryCache = queryClient.getQueryCache()
35+
36+
// `restoreMocks: true` (vite.config.ts) clears mock state between tests,
37+
// so restore the default behavior for the shared channel mock.
38+
channelMock.postMessage.mockReset().mockResolvedValue(undefined)
39+
channelMock.close.mockReset()
40+
channelMock.onmessage = null
1341
})
1442

1543
it('should subscribe to the query cache', () => {
@@ -28,4 +56,93 @@ describe('broadcastQueryClient', () => {
2856
unsubscribe()
2957
expect(queryCache.hasListeners()).toBe(false)
3058
})
59+
60+
describe('when postMessage rejects (non-cloneable payload)', () => {
61+
it('routes the failure to onBroadcastError with the originating query metadata', async () => {
62+
const cloneError = new DOMException(
63+
'A ReadableStream could not be cloned because it was not transferred.',
64+
'DataCloneError',
65+
)
66+
channelMock.postMessage.mockRejectedValue(cloneError)
67+
68+
const errors: Array<{ error: unknown; event: BroadcastErrorEvent }> = []
69+
broadcastQueryClient({
70+
queryClient,
71+
broadcastChannel: 'test_channel',
72+
onBroadcastError: (error, event) => {
73+
errors.push({ error, event })
74+
},
75+
})
76+
77+
// `setQueryData` on a fresh query triggers both `added` (from the
78+
// build step) and `updated` (from the success dispatch). Both
79+
// broadcasts should fail and both failures should reach the hook.
80+
queryClient.setQueryData(['stream'], { body: 'non-cloneable' })
81+
82+
await vi.waitFor(() => {
83+
expect(errors).toHaveLength(2)
84+
})
85+
86+
const eventTypes = errors.map(({ event }) => event.type)
87+
expect(eventTypes).toContain('added')
88+
expect(eventTypes).toContain('updated')
89+
90+
for (const { error, event } of errors) {
91+
expect(error).toBe(cloneError)
92+
expect(event.queryKey).toEqual(['stream'])
93+
expect(event.queryHash).toEqual(expect.any(String))
94+
}
95+
})
96+
97+
it('falls back to console.warn in development when no hook is provided', async () => {
98+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
99+
channelMock.postMessage.mockRejectedValue(
100+
new DOMException('clone failed', 'DataCloneError'),
101+
)
102+
103+
broadcastQueryClient({
104+
queryClient,
105+
broadcastChannel: 'test_channel',
106+
})
107+
108+
queryClient.setQueryData(['stream'], { body: 'non-cloneable' })
109+
110+
await vi.waitFor(() => {
111+
expect(warn).toHaveBeenCalled()
112+
})
113+
114+
const [firstCall] = warn.mock.calls
115+
expect(firstCall?.[0]).toEqual(
116+
expect.stringContaining('[broadcastQueryClient]'),
117+
)
118+
})
119+
120+
it('does not surface broadcast failures as unhandled rejections', async () => {
121+
const onUnhandled = vi.fn()
122+
process.on('unhandledRejection', onUnhandled)
123+
124+
try {
125+
channelMock.postMessage.mockRejectedValue(
126+
new DOMException('clone failed', 'DataCloneError'),
127+
)
128+
129+
broadcastQueryClient({
130+
queryClient,
131+
broadcastChannel: 'test_channel',
132+
// Silent hook - the assertion is on the rejection path, not on the
133+
// hook's observability side effect.
134+
onBroadcastError: () => {},
135+
})
136+
137+
queryClient.setQueryData(['stream'], { body: 'non-cloneable' })
138+
139+
// Let Node's microtask queue and `unhandledRejection` scheduler run.
140+
await new Promise((resolve) => setTimeout(resolve, 20))
141+
142+
expect(onUnhandled).not.toHaveBeenCalled()
143+
} finally {
144+
process.off('unhandledRejection', onUnhandled)
145+
}
146+
})
147+
})
31148
})

packages/query-broadcast-client-experimental/src/index.ts

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,52 @@
11
import { BroadcastChannel } from 'broadcast-channel'
22
import type { BroadcastChannelOptions } from 'broadcast-channel'
3-
import type { QueryClient } from '@tanstack/query-core'
3+
import type { QueryClient, QueryKey } from '@tanstack/query-core'
4+
5+
/**
6+
* Metadata describing a broadcast message that could not be delivered to
7+
* other tabs. Passed to {@link BroadcastQueryClientOptions.onBroadcastError}
8+
* so callers can correlate failures with the originating query.
9+
*/
10+
export interface BroadcastErrorEvent {
11+
type: 'added' | 'removed' | 'updated'
12+
queryHash: string
13+
queryKey: QueryKey
14+
}
415

516
interface BroadcastQueryClientOptions {
17+
/** The QueryClient to sync. */
618
queryClient: QueryClient
19+
/**
20+
* Unique channel name used to communicate between tabs and windows.
21+
* @default 'tanstack-query'
22+
*/
723
broadcastChannel?: string
24+
/** Options forwarded to the underlying `BroadcastChannel`. */
825
options?: BroadcastChannelOptions
26+
/**
27+
* Called when a query event fails to broadcast to other tabs - most
28+
* commonly when the query's `state.data`, `state.error`, or `queryKey`
29+
* contains a value the structured-clone algorithm cannot serialize
30+
* (e.g. `ReadableStream`, `File`, functions, Vue `reactive` proxies).
31+
*
32+
* Provide this hook to route failures to an error tracker without
33+
* producing unhandled promise rejections. If omitted, a `console.warn`
34+
* is emitted in development so cross-tab sync failures are never
35+
* entirely silent.
36+
*/
37+
onBroadcastError?: (error: unknown, event: BroadcastErrorEvent) => void
938
}
1039

40+
type BroadcastMessage =
41+
| { type: 'added'; queryHash: string; queryKey: QueryKey }
42+
| { type: 'removed'; queryHash: string; queryKey: QueryKey }
43+
| { type: 'updated'; queryHash: string; queryKey: QueryKey; state: unknown }
44+
1145
export function broadcastQueryClient({
1246
queryClient,
1347
broadcastChannel = 'tanstack-query',
1448
options,
49+
onBroadcastError,
1550
}: BroadcastQueryClientOptions): () => void {
1651
let transaction = false
1752
const tx = (cb: () => void) => {
@@ -27,7 +62,34 @@ export function broadcastQueryClient({
2762

2863
const queryCache = queryClient.getQueryCache()
2964

30-
const unsubscribe = queryClient.getQueryCache().subscribe((queryEvent) => {
65+
// `broadcast-channel`'s `postMessage` returns a `Promise<void>` that rejects
66+
// when the payload cannot be structured-cloned. Attach a catch handler so
67+
// the rejection never escapes as an `unhandledrejection`, and surface the
68+
// offending query through the user's hook or a development-only warning.
69+
const safePost = (message: BroadcastMessage): void => {
70+
channel.postMessage(message).catch((error: unknown) => {
71+
const event: BroadcastErrorEvent = {
72+
type: message.type,
73+
queryHash: message.queryHash,
74+
queryKey: message.queryKey,
75+
}
76+
77+
if (onBroadcastError) {
78+
onBroadcastError(error, event)
79+
return
80+
}
81+
82+
if (process.env.NODE_ENV !== 'production') {
83+
console.warn(
84+
`[broadcastQueryClient] Failed to broadcast "${event.type}" event for query ${event.queryHash}. ` +
85+
'The query value could not be structured-cloned; cross-tab sync for this query was skipped.',
86+
error,
87+
)
88+
}
89+
})
90+
}
91+
92+
const unsubscribe = queryCache.subscribe((queryEvent) => {
3193
if (transaction) {
3294
return
3395
}
@@ -37,28 +99,15 @@ export function broadcastQueryClient({
3799
} = queryEvent
38100

39101
if (queryEvent.type === 'updated' && queryEvent.action.type === 'success') {
40-
channel.postMessage({
41-
type: 'updated',
42-
queryHash,
43-
queryKey,
44-
state,
45-
})
102+
safePost({ type: 'updated', queryHash, queryKey, state })
46103
}
47104

48105
if (queryEvent.type === 'removed' && observers.length > 0) {
49-
channel.postMessage({
50-
type: 'removed',
51-
queryHash,
52-
queryKey,
53-
})
106+
safePost({ type: 'removed', queryHash, queryKey })
54107
}
55108

56109
if (queryEvent.type === 'added') {
57-
channel.postMessage({
58-
type: 'added',
59-
queryHash,
60-
queryKey,
61-
})
110+
safePost({ type: 'added', queryHash, queryKey })
62111
}
63112
})
64113

0 commit comments

Comments
 (0)