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
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -28,4 +28,67 @@ describe('broadcastQueryClient', () => {
unsubscribe()
expect(queryCache.hasListeners()).toBe(false)
})

describe('postMessage error handling', () => {
let unhandledRejections: Array<PromiseRejectionEvent>

beforeEach(() => {
unhandledRejections = []
window.addEventListener('unhandledrejection', (e) => {
unhandledRejections.push(e)
e.preventDefault()
})
})

afterEach(() => {
window.removeEventListener('unhandledrejection', () => {})
})
Comment on lines +35 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

unhandledrejection listener is never removed — leaks across tests.

removeEventListener is called with a brand-new () => {}, which is a different reference from the anonymous handler registered in beforeEach. The original listener is never detached, so each test in this file (and any subsequent suite sharing the same window) accumulates a stale listener still pushing into a captured unhandledRejections array. Capture the handler in a variable and remove that same reference.

🔧 Proposed fix
   describe('postMessage error handling', () => {
     let unhandledRejections: Array<PromiseRejectionEvent>
+    let onUnhandledRejection: (e: PromiseRejectionEvent) => void

     beforeEach(() => {
       unhandledRejections = []
-      window.addEventListener('unhandledrejection', (e) => {
+      onUnhandledRejection = (e) => {
         unhandledRejections.push(e)
         e.preventDefault()
-      })
+      }
+      window.addEventListener('unhandledrejection', onUnhandledRejection)
     })

     afterEach(() => {
-      window.removeEventListener('unhandledrejection', () => {})
+      window.removeEventListener('unhandledrejection', onUnhandledRejection)
     })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
beforeEach(() => {
unhandledRejections = []
window.addEventListener('unhandledrejection', (e) => {
unhandledRejections.push(e)
e.preventDefault()
})
})
afterEach(() => {
window.removeEventListener('unhandledrejection', () => {})
})
let unhandledRejections: Array<PromiseRejectionEvent>
let onUnhandledRejection: (e: PromiseRejectionEvent) => void
beforeEach(() => {
unhandledRejections = []
onUnhandledRejection = (e) => {
unhandledRejections.push(e)
e.preventDefault()
}
window.addEventListener('unhandledrejection', onUnhandledRejection)
})
afterEach(() => {
window.removeEventListener('unhandledrejection', onUnhandledRejection)
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/query-broadcast-client-experimental/src/__tests__/index.test.ts`
around lines 35 - 45, The unhandledrejection listener added in the test setup is
never removed because afterEach passes a different anonymous function to
removeEventListener than the one registered in beforeEach. Update the test in
index.test.ts to store the handler in a named or scoped variable alongside
unhandledRejections, use that same reference for both addEventListener and
removeEventListener, and keep the listener lifecycle tied to each test.


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()
})
})
})
26 changes: 23 additions & 3 deletions packages/query-broadcast-client-experimental/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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
Expand All @@ -37,7 +57,7 @@ export function broadcastQueryClient({
} = queryEvent

if (queryEvent.type === 'updated' && queryEvent.action.type === 'success') {
channel.postMessage({
safePost({
type: 'updated',
queryHash,
queryKey,
Expand All @@ -46,15 +66,15 @@ export function broadcastQueryClient({
}

if (queryEvent.type === 'removed' && observers.length > 0) {
channel.postMessage({
safePost({
type: 'removed',
queryHash,
queryKey,
})
}

if (queryEvent.type === 'added') {
channel.postMessage({
safePost({
type: 'added',
queryHash,
queryKey,
Expand Down