Skip to content

fix(query-broadcast-client-experimental): catch postMessage rejections to prevent unhandled DataCloneErrors#10980

Open
tsushanth wants to merge 1 commit into
TanStack:mainfrom
tsushanth:fix/broadcast-client-postmessage-unhandled-rejection
Open

fix(query-broadcast-client-experimental): catch postMessage rejections to prevent unhandled DataCloneErrors#10980
tsushanth wants to merge 1 commit into
TanStack:mainfrom
tsushanth:fix/broadcast-client-postmessage-unhandled-rejection

Conversation

@tsushanth

@tsushanth tsushanth commented Jun 24, 2026

Copy link
Copy Markdown

Fixes #10542

Problem

broadcastQueryClient subscribes to the query cache and calls channel.postMessage() on three paths (updated, removed, added). The returned promise was never caught, so any value the structured-clone algorithm cannot serialize — ReadableStream, Response, File, functions, Vue reactive proxies, MobX observables, etc. — causes a DataCloneError that surfaces as an unhandled rejection:

DataCloneError: Failed to execute 'postMessage' on 'BroadcastChannel':
  A ReadableStream could not be cloned because it was not transferred.
    at postMessage (broadcast-channel/.../methods/native.js:24)

The stack points into node_modules with no indication of which query triggered it, making the error effectively unactionable in Sentry / Datadog. Cross-tab sync for that update is silently dropped while the error tracker accumulates noise.

Solution

Introduce a safePost helper that wraps all three channel.postMessage() call sites in .catch(). The default behavior is a console.warn in non-production builds that includes the event type and offending queryHash, making the failure locally actionable without polluting production error trackers.

Callers who want to route errors to their own monitoring can supply an onBroadcastError callback:

broadcastQueryClient({
  queryClient,
  onBroadcastError: (error, message) => {
    Sentry.captureException(error, {
      extra: { queryHash: message.queryHash, type: message.type },
    })
  },
})

onBroadcastError receives the raw error and the attempted BroadcastMessage (including type and queryHash), giving enough context to filter noise or tie the failure back to a specific query.

Changes

  • src/index.ts — adds BroadcastMessage type, onBroadcastError option, and safePost helper; replaces the three bare channel.postMessage() calls
  • src/__tests__/index.test.ts — adds two new tests: one asserting no unhandledrejection fires when postMessage rejects, and one asserting onBroadcastError is called with the correct error and queryHash

All four tests pass.

Summary by CodeRabbit

  • Bug Fixes
    • Improved error handling when broadcast updates fail, reducing the chance of unhandled promise rejections.
    • Added a callback option to report broadcast errors and included clearer warning behavior in non-production environments.
    • Strengthened test coverage for broadcast failure scenarios to ensure errors are handled safely.

…s 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 TanStack#10542
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a safePost helper to broadcastQueryClient that wraps channel.postMessage with a .catch() handler. On failure it calls a new optional onBroadcastError callback or emits a dev-only console.warn. All three query-cache event handlers (updated, removed, added) are routed through safePost. Tests verify no unhandled rejections occur and that onBroadcastError is invoked correctly.

Changes

BroadcastQueryClient postMessage error handling

Layer / File(s) Summary
BroadcastMessage type, safePost helper, and event handler wiring
packages/query-broadcast-client-experimental/src/index.ts
Defines BroadcastMessage union type and adds optional onBroadcastError to BroadcastQueryClientOptions. Introduces safePost that catches rejected postMessage promises and routes them to onBroadcastError or a dev-only console.warn. Replaces all three direct channel.postMessage(...) call sites with safePost(...).
postMessage error handling tests
packages/query-broadcast-client-experimental/src/__tests__/index.test.ts
Extends Vitest imports with afterEach and vi. Adds a describe('postMessage error handling') block with unhandledrejection tracking hooks and two async tests: one asserting DataCloneError on a non-cloneable value produces no unhandled rejections, and one monkey-patching BroadcastChannel.prototype.postMessage to confirm onBroadcastError receives the error and correct queryHash.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 A postMessage flew, but couldn't be cloned,
Once it just crashed and silently groaned.
Now safePost catches the error with care,
Calls back onBroadcastError if you dare!
No unhandled rejections to muddy the air—
This rabbit hops safely from tab to tab, fair. 🎉

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the bug and solution, but it does not follow the required template sections for Changes, Checklist, and Release Impact. Rewrite the PR description using the required template headings and include the checklist items and release impact/changset status.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: catching postMessage rejections to avoid unhandled DataCloneErrors.
Linked Issues check ✅ Passed The PR addresses the linked issue by catching all three postMessage paths, adding onBroadcastError, and testing no unhandledrejection occurs.
Out of Scope Changes check ✅ Passed The changes stay focused on postMessage error handling and tests, with no clear unrelated code paths introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/query-broadcast-client-experimental/src/__tests__/index.test.ts (1)

78-86: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Restore the prototype patch in a finally block.

If any assertion or setQueryData between lines 80–86 throws, BC.prototype.postMessage is left monkey-patched, leaking the rejection behavior into later tests. Wrap the patched section so restoration always runs.

♻️ Proposed fix
       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
+      try {
+        queryClient.setQueryData(['key-for-error-test'], 'value')
+        await new Promise((resolve) => setTimeout(resolve, 50))
+      } finally {
+        BC.prototype.postMessage = originalPost
+      }
🤖 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 78 - 86, The test in the BroadcastChannel patch section leaves
BC.prototype.postMessage monkey-patched if setQueryData or an assertion throws.
Wrap the temporary override in a try/finally so the original postMessage is
always restored, using the BroadcastChannel prototype patch around
queryClient.setQueryData and the subsequent wait.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@packages/query-broadcast-client-experimental/src/__tests__/index.test.ts`:
- Around line 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.

---

Nitpick comments:
In `@packages/query-broadcast-client-experimental/src/__tests__/index.test.ts`:
- Around line 78-86: The test in the BroadcastChannel patch section leaves
BC.prototype.postMessage monkey-patched if setQueryData or an assertion throws.
Wrap the temporary override in a try/finally so the original postMessage is
always restored, using the BroadcastChannel prototype patch around
queryClient.setQueryData and the subsequent wait.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a30b5e43-9477-4163-9f59-6884b09af5f5

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb0ed9 and 30d0c02.

📒 Files selected for processing (2)
  • packages/query-broadcast-client-experimental/src/__tests__/index.test.ts
  • packages/query-broadcast-client-experimental/src/index.ts

Comment on lines +35 to +45
beforeEach(() => {
unhandledRejections = []
window.addEventListener('unhandledrejection', (e) => {
unhandledRejections.push(e)
e.preventDefault()
})
})

afterEach(() => {
window.removeEventListener('unhandledrejection', () => {})
})

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[query-broadcast-client-experimental] postMessage failures surface as unhandled DataCloneError rejections

2 participants