From c543e8c3c560a4cba04b9c0127ad658f3534b6ee Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Sun, 24 May 2026 15:48:41 +0530 Subject: [PATCH 1/2] fix(query-broadcast-client): handle postMessage errors --- .changeset/broadcast-client-post-errors.md | 5 ++ .../src/__tests__/index.test.ts | 71 ++++++++++++++++++- .../src/index.ts | 44 ++++++++++-- 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 .changeset/broadcast-client-post-errors.md diff --git a/.changeset/broadcast-client-post-errors.md b/.changeset/broadcast-client-post-errors.md new file mode 100644 index 00000000000..e82ee3edc87 --- /dev/null +++ b/.changeset/broadcast-client-post-errors.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-broadcast-client-experimental': patch +--- + +Handle broadcast `postMessage` failures without surfacing unhandled rejections. 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..52c0f4790dc 100644 --- a/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts +++ b/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts @@ -1,17 +1,49 @@ 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' +const broadcastChannelMock = vi.hoisted(() => { + const channels: Array<{ + postMessage: ReturnType + close: ReturnType + onmessage: ((message: unknown) => void) | null + }> = [] + + class BroadcastChannel { + onmessage: ((message: unknown) => void) | null = null + postMessage = vi.fn(() => Promise.resolve()) + close = vi.fn() + + constructor(_name: string, _options: unknown) { + channels.push(this) + } + } + + return { + BroadcastChannel, + channels, + } +}) + +vi.mock('broadcast-channel', () => ({ + BroadcastChannel: broadcastChannelMock.BroadcastChannel, +})) + describe('broadcastQueryClient', () => { let queryClient: QueryClient let queryCache: QueryCache beforeEach(() => { + broadcastChannelMock.channels.length = 0 queryClient = new QueryClient() queryCache = queryClient.getQueryCache() }) + afterEach(() => { + vi.restoreAllMocks() + }) + it('should subscribe to the query cache', () => { broadcastQueryClient({ queryClient, @@ -28,4 +60,41 @@ describe('broadcastQueryClient', () => { unsubscribe() expect(queryCache.hasListeners()).toBe(false) }) + + it('should report postMessage rejections without unhandled rejections', async () => { + const error = new DOMException('cannot clone', 'DataCloneError') + const onBroadcastError = vi.fn() + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined) + + broadcastQueryClient({ + queryClient, + broadcastChannel: 'test_channel', + onBroadcastError, + }) + + const channel = broadcastChannelMock.channels[0]! + channel.postMessage.mockImplementation((message: { type: string }) => + message.type === 'updated' ? Promise.reject(error) : Promise.resolve(), + ) + + queryClient.setQueryData(['stream'], new ReadableStream()) + + await vi.waitFor(() => { + expect(onBroadcastError).toHaveBeenCalledWith( + error, + expect.objectContaining({ + type: 'updated', + queryHash: '["stream"]', + queryKey: ['stream'], + }), + ) + }) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[broadcastQueryClient] failed to broadcast "updated" for queryHash "["stream"]"', + error, + ) + }) }) diff --git a/packages/query-broadcast-client-experimental/src/index.ts b/packages/query-broadcast-client-experimental/src/index.ts index e102b3c0b01..b96e4a39bd9 100644 --- a/packages/query-broadcast-client-experimental/src/index.ts +++ b/packages/query-broadcast-client-experimental/src/index.ts @@ -1,17 +1,32 @@ import { BroadcastChannel } from 'broadcast-channel' import type { BroadcastChannelOptions } from 'broadcast-channel' -import type { QueryClient } from '@tanstack/query-core' +import type { QueryClient, QueryKey, QueryState } from '@tanstack/query-core' interface BroadcastQueryClientOptions { queryClient: QueryClient broadcastChannel?: string options?: BroadcastChannelOptions + onBroadcastError?: (error: unknown, message: BroadcastMessage) => void } +type BroadcastMessage = + | { + type: 'updated' + queryHash: string + queryKey: QueryKey + state: QueryState + } + | { + type: 'removed' | 'added' + queryHash: string + queryKey: QueryKey + } + export function broadcastQueryClient({ queryClient, broadcastChannel = 'tanstack-query', options, + onBroadcastError, }: BroadcastQueryClientOptions): () => void { let transaction = false const tx = (cb: () => void) => { @@ -27,6 +42,27 @@ export function broadcastQueryClient({ const queryCache = queryClient.getQueryCache() + const handleBroadcastError = (error: unknown, message: BroadcastMessage) => { + onBroadcastError?.(error, message) + + if (process.env.NODE_ENV !== 'production') { + console.warn( + `[broadcastQueryClient] failed to broadcast "${message.type}" for queryHash "${message.queryHash}"`, + error, + ) + } + } + + const postMessage = (message: BroadcastMessage) => { + try { + void channel + .postMessage(message) + .catch((error) => handleBroadcastError(error, message)) + } catch (error) { + handleBroadcastError(error, message) + } + } + const unsubscribe = queryClient.getQueryCache().subscribe((queryEvent) => { if (transaction) { return @@ -37,7 +73,7 @@ export function broadcastQueryClient({ } = queryEvent if (queryEvent.type === 'updated' && queryEvent.action.type === 'success') { - channel.postMessage({ + postMessage({ type: 'updated', queryHash, queryKey, @@ -46,7 +82,7 @@ export function broadcastQueryClient({ } if (queryEvent.type === 'removed' && observers.length > 0) { - channel.postMessage({ + postMessage({ type: 'removed', queryHash, queryKey, @@ -54,7 +90,7 @@ export function broadcastQueryClient({ } if (queryEvent.type === 'added') { - channel.postMessage({ + postMessage({ type: 'added', queryHash, queryKey, From e11f99464c5b52a78e71ea8090bc21205cec40de Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Sun, 24 May 2026 22:43:57 +0530 Subject: [PATCH 2/2] test(query-broadcast-client): cover sync broadcast errors --- .../src/__tests__/index.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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 52c0f4790dc..8a3f2d83613 100644 --- a/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts +++ b/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts @@ -97,4 +97,44 @@ describe('broadcastQueryClient', () => { error, ) }) + + it('should report synchronous postMessage throws', async () => { + const error = new DOMException('cannot clone', 'DataCloneError') + const onBroadcastError = vi.fn() + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined) + + broadcastQueryClient({ + queryClient, + broadcastChannel: 'test_channel', + onBroadcastError, + }) + + const channel = broadcastChannelMock.channels[0]! + channel.postMessage.mockImplementation((message: { type: string }) => { + if (message.type === 'updated') { + throw error + } + return Promise.resolve() + }) + + queryClient.setQueryData(['stream'], new ReadableStream()) + + await vi.waitFor(() => { + expect(onBroadcastError).toHaveBeenCalledWith( + error, + expect.objectContaining({ + type: 'updated', + queryHash: '["stream"]', + queryKey: ['stream'], + }), + ) + }) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[broadcastQueryClient] failed to broadcast "updated" for queryHash "["stream"]"', + error, + ) + }) })