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,