From 30d0c025b6d7cf0e653dc3b6474a0ab5d042021b Mon Sep 17 00:00:00 2001 From: sushanthtiruvaipati Date: Wed, 24 Jun 2026 10:28:14 -0700 Subject: [PATCH] fix(query-broadcast-client-experimental): catch postMessage rejections to prevent unhandled DataCloneErrors When query state contains a value the structured-clone algorithm cannot serialize (ReadableStream, Response, File, functions, Vue reactive proxies, MobX observables, etc.), every postMessage call on the three subscription paths throws a DataCloneError. Because the returned promise was never caught, these became unhandled rejections that polluted error trackers with stack traces pointing into node_modules and no indication of which query was at fault. This commit introduces a `safePost` helper that wraps all three `channel.postMessage()` call sites with a `.catch()`. By default the caught error is logged as a `console.warn` in non-production builds so developers still get actionable feedback (including the offending `queryHash`). Callers can supply an `onBroadcastError` option to route errors wherever they want (Sentry, Datadog, etc.) without forking the package. The `onBroadcastError` callback receives the raw error and the attempted `BroadcastMessage` object (which includes `type` and `queryHash`), giving enough context to filter noise or attach it to a specific query in user-land monitoring. Tests added: - asserts no `unhandledrejection` event fires when postMessage rejects - asserts `onBroadcastError` is called with the correct error and queryHash Fixes #10542 --- .../src/__tests__/index.test.ts | 65 ++++++++++++++++++- .../src/index.ts | 26 +++++++- 2 files changed, 87 insertions(+), 4 deletions(-) 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..02580f16f4f 100644 --- a/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts +++ b/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts @@ -1,5 +1,5 @@ import { QueryClient } from '@tanstack/query-core' -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { broadcastQueryClient } from '..' import type { QueryCache } from '@tanstack/query-core' @@ -28,4 +28,67 @@ describe('broadcastQueryClient', () => { unsubscribe() expect(queryCache.hasListeners()).toBe(false) }) + + describe('postMessage error handling', () => { + let unhandledRejections: Array + + beforeEach(() => { + unhandledRejections = [] + window.addEventListener('unhandledrejection', (e) => { + unhandledRejections.push(e) + e.preventDefault() + }) + }) + + afterEach(() => { + window.removeEventListener('unhandledrejection', () => {}) + }) + + it('should not surface DataCloneError as an unhandledrejection', async () => { + const unsubscribe = broadcastQueryClient({ + queryClient, + broadcastChannel: 'test_channel_clone_error', + }) + + // Functions cannot be structured-cloned; setting one as query data + // triggers a DataCloneError from postMessage + queryClient.setQueryData(['non-cloneable'], () => 'fn') + + // Give the microtask queue a chance to settle + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(unhandledRejections).toHaveLength(0) + unsubscribe() + }) + + it('should invoke onBroadcastError with the error and message when postMessage fails', async () => { + const cloneError = new DOMException('could not be cloned', 'DataCloneError') + const errors: Array<{ error: unknown; queryHash: string }> = [] + + const unsubscribe = broadcastQueryClient({ + queryClient, + broadcastChannel: 'test_channel_on_error', + onBroadcastError: (error, message) => { + errors.push({ error, queryHash: message.queryHash }) + }, + }) + + // Simulate a postMessage rejection by overriding the channel's postMessage + // on the BroadcastChannel prototype so the next call rejects + const { BroadcastChannel: BC } = await import('broadcast-channel') + const originalPost = BC.prototype.postMessage + BC.prototype.postMessage = () => Promise.reject(cloneError) + + queryClient.setQueryData(['key-for-error-test'], 'value') + + await new Promise((resolve) => setTimeout(resolve, 50)) + + BC.prototype.postMessage = originalPost + + expect(errors.length).toBeGreaterThan(0) + expect(errors[0]!.queryHash).toBe(JSON.stringify(['key-for-error-test'])) + expect(errors[0]!.error).toBe(cloneError) + unsubscribe() + }) + }) }) diff --git a/packages/query-broadcast-client-experimental/src/index.ts b/packages/query-broadcast-client-experimental/src/index.ts index e102b3c0b01..bb883bd8671 100644 --- a/packages/query-broadcast-client-experimental/src/index.ts +++ b/packages/query-broadcast-client-experimental/src/index.ts @@ -2,16 +2,23 @@ import { BroadcastChannel } from 'broadcast-channel' import type { BroadcastChannelOptions } from 'broadcast-channel' import type { QueryClient } from '@tanstack/query-core' +type BroadcastMessage = + | { type: 'updated'; queryHash: string; queryKey: unknown; state: unknown } + | { type: 'removed'; queryHash: string; queryKey: unknown } + | { type: 'added'; queryHash: string; queryKey: unknown } + interface BroadcastQueryClientOptions { queryClient: QueryClient broadcastChannel?: string options?: BroadcastChannelOptions + onBroadcastError?: (error: unknown, message: BroadcastMessage) => void } export function broadcastQueryClient({ queryClient, broadcastChannel = 'tanstack-query', options, + onBroadcastError, }: BroadcastQueryClientOptions): () => void { let transaction = false const tx = (cb: () => void) => { @@ -27,6 +34,19 @@ export function broadcastQueryClient({ const queryCache = queryClient.getQueryCache() + const safePost = (message: BroadcastMessage) => { + channel.postMessage(message).catch((error: unknown) => { + if (onBroadcastError) { + onBroadcastError(error, message) + } else if (process.env.NODE_ENV !== 'production') { + console.warn( + `[broadcastQueryClient] failed to broadcast "${message.type}" for queryHash "${message.queryHash}":`, + error, + ) + } + }) + } + const unsubscribe = queryClient.getQueryCache().subscribe((queryEvent) => { if (transaction) { return @@ -37,7 +57,7 @@ export function broadcastQueryClient({ } = queryEvent if (queryEvent.type === 'updated' && queryEvent.action.type === 'success') { - channel.postMessage({ + safePost({ type: 'updated', queryHash, queryKey, @@ -46,7 +66,7 @@ export function broadcastQueryClient({ } if (queryEvent.type === 'removed' && observers.length > 0) { - channel.postMessage({ + safePost({ type: 'removed', queryHash, queryKey, @@ -54,7 +74,7 @@ export function broadcastQueryClient({ } if (queryEvent.type === 'added') { - channel.postMessage({ + safePost({ type: 'added', queryHash, queryKey,