Skip to content

fix(broadcast-client): handle unhandled postMessage rejections - #10771

Merged
TkDodo merged 11 commits into
TanStack:mainfrom
n-satoshi061:fix/broadcast-client-unhandled-postmessage-rejection
Aug 20, 2026
Merged

fix(broadcast-client): handle unhandled postMessage rejections#10771
TkDodo merged 11 commits into
TanStack:mainfrom
n-satoshi061:fix/broadcast-client-unhandled-postmessage-rejection

Conversation

@n-satoshi061

@n-satoshi061 n-satoshi061 commented May 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #10543

broadcastQueryClient calls channel.postMessage() three times (on updated, removed, added) without .catch()-ing the returned promise. When query data contains a non-cloneable value (ReadableStream, Response, File, functions, Vue/MobX reactive proxies, etc.), the rejection becomes an unhandled DataCloneError with a stack pointing into node_modules — effectively unactionable in Sentry/Datadog.

Changes

  • Wrap all channel.postMessage() calls in a safePost() helper that catches rejections
  • Add optional onBroadcastError callback to BroadcastQueryClientOptions so consumers can route errors to their own error tracker with the offending queryHash
  • Fall back to console.warn in development when onBroadcastError is not provided
  • Silent in production (no unhandled rejection, no console noise)

Usage

broadcastQueryClient({
  queryClient,
  broadcastChannel: 'my-app',
  onBroadcastError: (error, message) => {
    Sentry.captureException(error, {
      extra: { queryHash: message.queryHash, type: message.type },
    })
  },
})

Test plan

  • onBroadcastError is called with the error and message when postMessage rejects
  • console.warn is emitted in development when no onBroadcastError is provided
  • No warning in production when postMessage fails
  • Existing tests still pass
  • ESLint passes

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Prevented unhandled rejections when cross-tab broadcasts fail due to non-serializable data.
    • Failed synchronization now skips only the affected query while other broadcasts continue.
  • New Features

    • Added an optional broadcast-error callback with event details for custom error reporting.
    • Development builds provide console warnings when no error handler is configured.
    • Reduced broadcast payload size for removed items.
  • Documentation

    • Added guidance for handling broadcast errors.
  • Tests

    • Expanded coverage for failures, callbacks, rejected handlers, and warnings.

Wrap channel.postMessage() calls in safePost() to catch DataCloneError
and similar failures that occur when query data contains non-cloneable
values (ReadableStream, Response, Vue reactive proxies, etc.).

Adds an optional onBroadcastError callback to BroadcastQueryClientOptions
so consumers can pipe errors to Sentry/Datadog. Falls back to console.warn
in development when the callback is not provided.

Fixes TanStack#10543

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f6bcdf99-3e41-4fd2-a9de-8c771bf6854e

📥 Commits

Reviewing files that changed from the base of the PR and between b0e7d7d and 3e3623e.

📒 Files selected for processing (1)
  • docs/framework/react/plugins/broadcastQueryClient.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/framework/react/plugins/broadcastQueryClient.md

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

broadcastQueryClient now routes rejected BroadcastChannel.postMessage calls through safePost. It supports onBroadcastError, catches callback failures, and warns in development. Tests, documentation, and release metadata cover the behavior.

Changes

Broadcast error handling and recovery

Layer / File(s) Summary
Options interface and callback setup
packages/query-broadcast-client-experimental/src/index.ts
Adds BroadcastErrorEvent and the optional onBroadcastError callback.
Safe post helper with error handling
packages/query-broadcast-client-experimental/src/index.ts
Handles send failures, callback errors, promise rejections, and development-only warnings.
Broadcast integration
packages/query-broadcast-client-experimental/src/index.ts
Routes updated, removed, and added messages through safePost. Updated and added messages include state; removed messages do not.
Tests
packages/query-broadcast-client-experimental/src/__tests__/index.test.ts
Tests callback metadata, callback failure handling, and development versus production warning behavior.
Documentation and release metadata
docs/framework/react/plugins/broadcastQueryClient.md, .changeset/fix-broadcast-client-unhandled-rejection.md
Documents the API and failure behavior, and adds patch release metadata.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 3e362

The change contains broadcast message rejection handling and optional error reporting without introducing a supported merge-blocking risk; no actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant QueryCache
  participant BroadcastQueryClient
  participant BroadcastChannel
  participant ErrorHandler
  QueryCache->>BroadcastQueryClient: query update, add, or removal
  BroadcastQueryClient->>BroadcastChannel: safePost(message)
  BroadcastChannel--xBroadcastQueryClient: postMessage rejection
  BroadcastQueryClient->>ErrorHandler: onBroadcastError(error, event)
  ErrorHandler-->>BroadcastQueryClient: completes, throws, or rejects
Loading

Possibly related PRs

Suggested labels: documentation

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
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 (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes handling unhandled postMessage rejections, which is the primary change.
Description check ✅ Passed The description clearly explains the motivation, implementation, usage, and test plan, although it does not use every template heading.
Linked Issues check ✅ Passed The changes address issue #10543 by catching postMessage failures, reporting query context, preventing unhandled rejections, and testing environment-specific behavior.
Out of Scope Changes check ✅ Passed The implementation, tests, documentation, and changeset all support the linked issue and stated broadcast error-handling objectives.
✨ 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)

60-78: ⚡ Quick win

Add a regression test for onBroadcastError throwing.

Please add a case where onBroadcastError throws, and assert no unhandled rejection escapes. That protects the error-handling contract end-to-end.

🤖 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 60 - 78, Add a test that simulates postMessage failing and the
supplied onBroadcastError itself throwing: mockPostMessage to reject with the
DOMException as before, pass an onBroadcastError mock that throws (e.g. throw
new Error('boom')), register a temporary process.on('unhandledRejection')
handler that fails the test if invoked, call broadcastQueryClient({ queryClient,
broadcastChannel: 'test_channel', onBroadcastError }), trigger the broadcast via
queryClient.setQueryData(['test'], { value: 1 }), await a microtask tick (e.g.
setTimeout 0 or next tick), then remove the unhandledRejection handler and
assert that onBroadcastError was called with the cloneError and that no
unhandled rejection handler fired.
🤖 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/index.ts`:
- Around line 41-43: The postMessage rejection handler currently calls
onBroadcastError(error, message) directly which can itself throw and convert the
rejection handler into an unhandled rejection; update the catch path that
contains the onBroadcastError invocation (in
packages/query-broadcast-client-experimental/src/index.ts — the block that
checks if (onBroadcastError) { onBroadcastError(error, message) } else if
(process.env.NODE_ENV !== 'production') { ... }) to invoke onBroadcastError
inside a local try/catch: call onBroadcastError(error, message) in the try, and
if it throws swallow or log that secondary error (fallback to the existing
console.warn behavior) so the original postMessage rejection handling remains
safe and cannot be escalated by a throwing callback.

---

Nitpick comments:
In `@packages/query-broadcast-client-experimental/src/__tests__/index.test.ts`:
- Around line 60-78: Add a test that simulates postMessage failing and the
supplied onBroadcastError itself throwing: mockPostMessage to reject with the
DOMException as before, pass an onBroadcastError mock that throws (e.g. throw
new Error('boom')), register a temporary process.on('unhandledRejection')
handler that fails the test if invoked, call broadcastQueryClient({ queryClient,
broadcastChannel: 'test_channel', onBroadcastError }), trigger the broadcast via
queryClient.setQueryData(['test'], { value: 1 }), await a microtask tick (e.g.
setTimeout 0 or next tick), then remove the unhandledRejection handler and
assert that onBroadcastError was called with the cloneError and that no
unhandled rejection handler fired.
🪄 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: 52ee48f6-f465-4bac-8951-d52f60b62ab6

📥 Commits

Reviewing files that changed from the base of the PR and between ba6e7be and 0893f0d.

📒 Files selected for processing (3)
  • .changeset/fix-broadcast-client-unhandled-rejection.md
  • packages/query-broadcast-client-experimental/src/__tests__/index.test.ts
  • packages/query-broadcast-client-experimental/src/index.ts

Comment thread packages/query-broadcast-client-experimental/src/index.ts
Wrap onBroadcastError() in a try/catch so that if the callback itself
throws, the error does not escape as an unhandled rejection. Falls back
to console.warn in development when the callback throws.

Add a test asserting no unhandledRejection fires when onBroadcastError throws.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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)

60-91: ⚡ Quick win

Add a regression test for rejected async handlers.

This test covers sync throw, but a Promise-rejecting onBroadcastError path is different and worth locking down with a dedicated case.

Suggested additional test
+    it('should not cause an unhandled rejection when onBroadcastError returns a rejected promise', async () => {
+      const cloneError = new DOMException('DataCloneError', 'DataCloneError')
+      mockPostMessage.mockRejectedValueOnce(cloneError)
+
+      const unhandledRejections: Array<unknown> = []
+      const onUnhandledRejection = (reason: unknown) => {
+        unhandledRejections.push(reason)
+      }
+      process.on('unhandledRejection', onUnhandledRejection)
+
+      const onBroadcastError = vi.fn().mockRejectedValue(new Error('boom-async'))
+
+      try {
+        broadcastQueryClient({
+          queryClient,
+          broadcastChannel: 'test_channel',
+          onBroadcastError,
+        })
+
+        queryClient.setQueryData(['test-async'], { value: 1 })
+        await new Promise((r) => setTimeout(r, 0))
+
+        expect(onBroadcastError).toHaveBeenCalledWith(
+          cloneError,
+          expect.objectContaining({ type: 'added' }),
+        )
+        expect(unhandledRejections).toHaveLength(0)
+      } finally {
+        process.off('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 60 - 91, Add a regression test that mirrors the existing
"onBroadcastError throws" case but instead uses an async/rejecting handler:
create an onBroadcastError stub that returns a rejected Promise (e.g.
vi.fn().mockRejectedValue(new Error('boom'))), call broadcastQueryClient({
queryClient, broadcastChannel: 'test_channel', onBroadcastError }), trigger a
broadcast via queryClient.setQueryData(['test'], { value: 1 }), capture process
'unhandledRejection' events into an array, wait a tick (setTimeout 0 or await
Promise.resolve()) then assert the mock was called with the cloneError and
expected message and that no unhandledRejections were recorded; reference
onBroadcastError, broadcastQueryClient, and queryClient.setQueryData when
locating where to add the test.
🤖 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/index.ts`:
- Around line 42-51: safePost currently calls onBroadcastError(...) inside a
synchronous try/catch which misses async rejections; update safePost to capture
the return value of onBroadcastError (e.g., const result =
onBroadcastError(error, message)) and handle both sync and async errors by
calling Promise.resolve(result).catch(err => { if (process.env.NODE_ENV !==
'production') console.warn(`[broadcastQueryClient] failed to broadcast
"${message.type}" for queryHash "${message.queryHash}":`, err); }); so async
callbacks are properly caught and logged without causing unhandledRejection.

---

Nitpick comments:
In `@packages/query-broadcast-client-experimental/src/__tests__/index.test.ts`:
- Around line 60-91: Add a regression test that mirrors the existing
"onBroadcastError throws" case but instead uses an async/rejecting handler:
create an onBroadcastError stub that returns a rejected Promise (e.g.
vi.fn().mockRejectedValue(new Error('boom'))), call broadcastQueryClient({
queryClient, broadcastChannel: 'test_channel', onBroadcastError }), trigger a
broadcast via queryClient.setQueryData(['test'], { value: 1 }), capture process
'unhandledRejection' events into an array, wait a tick (setTimeout 0 or await
Promise.resolve()) then assert the mock was called with the cloneError and
expected message and that no unhandledRejections were recorded; reference
onBroadcastError, broadcastQueryClient, and queryClient.setQueryData when
locating where to add the test.
🪄 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: 2357c389-bb7f-4815-9fcc-0db5fcf17887

📥 Commits

Reviewing files that changed from the base of the PR and between 0893f0d and 71ceeea.

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

Comment thread packages/query-broadcast-client-experimental/src/index.ts
Support async onBroadcastError callbacks by calling .catch() on the
returned Promise, preventing unhandled rejections when the callback
rejects asynchronously.

Also extracts the console.warn fallback into warnBroadcastError to
deduplicate the warning logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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.

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

101-118: ⚡ Quick win

Ensure the unhandledRejection listener is always cleaned up.

If this test throws before Line 117, the listener can leak into later tests. Wrap the body in try/finally and move process.off(...) to finally.

Proposed fix
       process.on('unhandledRejection', onUnhandledRejection)

       const onBroadcastError = vi
         .fn()
         .mockRejectedValueOnce(new Error('async boom'))

-      broadcastQueryClient({
-        queryClient,
-        broadcastChannel: 'test_channel',
-        onBroadcastError,
-      })
-
-      queryClient.setQueryData(['test'], { value: 1 })
-
-      await new Promise((r) => setTimeout(r, 10))
-
-      process.off('unhandledRejection', onUnhandledRejection)
-
-      expect(onBroadcastError).toHaveBeenCalledWith(
-        cloneError,
-        expect.objectContaining({ type: 'added' }),
-      )
-      expect(unhandledRejections).toHaveLength(0)
+      try {
+        broadcastQueryClient({
+          queryClient,
+          broadcastChannel: 'test_channel',
+          onBroadcastError,
+        })
+
+        queryClient.setQueryData(['test'], { value: 1 })
+
+        await new Promise((r) => setTimeout(r, 10))
+
+        expect(onBroadcastError).toHaveBeenCalledWith(
+          cloneError,
+          expect.objectContaining({ type: 'added' }),
+        )
+        expect(unhandledRejections).toHaveLength(0)
+      } finally {
+        process.off('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 101 - 118, The test registers an unhandledRejection listener
(onUnhandledRejection) but only removes it after awaited work, which can leak if
the test throws; wrap the test body that calls broadcastQueryClient({
queryClient, broadcastChannel: 'test_channel', onBroadcastError }) and the
subsequent queryClient.setQueryData/await in a try/finally and move
process.off('unhandledRejection', onUnhandledRejection) into the finally block
so the listener is always cleaned up even on failures; keep the same
onBroadcastError mock and onUnhandledRejection setup/teardown structure.
🤖 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.

Nitpick comments:
In `@packages/query-broadcast-client-experimental/src/__tests__/index.test.ts`:
- Around line 101-118: The test registers an unhandledRejection listener
(onUnhandledRejection) but only removes it after awaited work, which can leak if
the test throws; wrap the test body that calls broadcastQueryClient({
queryClient, broadcastChannel: 'test_channel', onBroadcastError }) and the
subsequent queryClient.setQueryData/await in a try/finally and move
process.off('unhandledRejection', onUnhandledRejection) into the finally block
so the listener is always cleaned up even on failures; keep the same
onBroadcastError mock and onUnhandledRejection setup/teardown structure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f614ddbd-4a69-46da-979b-42cb682551ca

📥 Commits

Reviewing files that changed from the base of the PR and between 71ceeea and 062cba0.

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

…listener cleanup

Prevents listener leaks into subsequent tests if an assertion throws
before process.off() is reached.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@n-satoshi061

Copy link
Copy Markdown
Contributor Author

Fixed in the latest commit: wrapped both unhandledRejection listener tests in try/finally so process.off() is always called even if an assertion throws.

n-satoshi061 and others added 2 commits May 26, 2026 10:07
- Export BroadcastErrorEvent interface with QueryKey typing so consumers
  can import the type directly
- Use discriminated union for internal BroadcastMessage (state only on
  updated) for more precise typing
- onBroadcastError now receives BroadcastErrorEvent (no internal state
  field) instead of the raw BroadcastMessage
- Differentiate dev warning messages: broadcast failures explain the
  structured-clone cause and consequence; onBroadcastError failures
  explicitly identify the hook as the source
- Remove warnBroadcastError helper in favour of context-specific inlined
  warn calls
- Fix duplicate queryClient.getQueryCache() call (use queryCache directly)
- Add JSDoc to all BroadcastQueryClientOptions fields
- Add onBroadcastError section to broadcastQueryClient docs with Sentry
  example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ows or rejects

Add two tests that verify the '[broadcastQueryClient] onBroadcastError
threw while handling...' console.warn message is emitted in development
when the onBroadcastError callback itself throws synchronously or rejects
asynchronously.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/query-broadcast-client-experimental/src/__tests__/index.test.ts (1)

163-204: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Restore console.warn with try/finally to avoid spy leakage.

If an assertion fails before mockRestore(), the spy can leak into later tests and cause cascading failures.

Proposed fix
 it('should warn in dev when postMessage fails and onBroadcastError is not provided', async () => {
   process.env['NODE_ENV'] = 'development'
   const cloneError = new DOMException('DataCloneError', 'DataCloneError')
   mockPostMessage.mockRejectedValueOnce(cloneError)

   const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

-  broadcastQueryClient({
-    queryClient,
-    broadcastChannel: 'test_channel',
-  })
-
-  queryClient.setQueryData(['test'], { value: 1 })
-
-  await new Promise((r) => setTimeout(r, 0))
-  expect(warnSpy).toHaveBeenCalledWith(
-    expect.stringContaining('cross-tab sync for this query was skipped'),
-    cloneError,
-  )
-
-  warnSpy.mockRestore()
+  try {
+    broadcastQueryClient({
+      queryClient,
+      broadcastChannel: 'test_channel',
+    })
+
+    queryClient.setQueryData(['test'], { value: 1 })
+
+    await new Promise((r) => setTimeout(r, 0))
+    expect(warnSpy).toHaveBeenCalledWith(
+      expect.stringContaining('cross-tab sync for this query was skipped'),
+      cloneError,
+    )
+  } finally {
+    warnSpy.mockRestore()
+  }
 })

 it('should not warn in production when postMessage fails', async () => {
   process.env['NODE_ENV'] = 'production'
   const cloneError = new DOMException('DataCloneError', 'DataCloneError')
   mockPostMessage.mockRejectedValueOnce(cloneError)

   const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

-  broadcastQueryClient({
-    queryClient,
-    broadcastChannel: 'test_channel',
-  })
-
-  queryClient.setQueryData(['test'], { value: 1 })
-
-  await new Promise((r) => setTimeout(r, 0))
-  expect(warnSpy).not.toHaveBeenCalled()
-
-  warnSpy.mockRestore()
+  try {
+    broadcastQueryClient({
+      queryClient,
+      broadcastChannel: 'test_channel',
+    })
+
+    queryClient.setQueryData(['test'], { value: 1 })
+
+    await new Promise((r) => setTimeout(r, 0))
+    expect(warnSpy).not.toHaveBeenCalled()
+  } finally {
+    warnSpy.mockRestore()
+  }
 })
🤖 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 163 - 204, Wrap the console.warn spy setup and assertions in a
try/finally so the spy is always restored even if an assertion fails;
specifically, in the two tests ("should warn in dev when postMessage fails and
onBroadcastError is not provided" and "should not warn in production when
postMessage fails") move the creation of warnSpy (vi.spyOn(console,
'warn').mockImplementation(() => {})) before the action, perform the
broadcastQueryClient(...) and queryClient.setQueryData(...) and the expect(...)
inside a try block, and call warnSpy.mockRestore() in the finally block to
ensure the spy is cleaned up; keep references to mockPostMessage,
broadcastQueryClient, queryClient, and warnSpy to locate the changes.
🧹 Nitpick comments (2)
docs/framework/react/plugins/broadcastQueryClient.md (1)

35-37: ⚡ Quick win

Update the API call snippet to reflect the new option.

The snippet still shows only queryClient and broadcastChannel; consider including onBroadcastError (or ...options) so the top-level API example matches the expanded options surface.

Suggested doc tweak
-broadcastQueryClient({ queryClient, broadcastChannel })
+broadcastQueryClient({ queryClient, broadcastChannel, onBroadcastError })
🤖 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 `@docs/framework/react/plugins/broadcastQueryClient.md` around lines 35 - 37,
Update the top-level API snippet for broadcastQueryClient to show the new
options surface by including the onBroadcastError option (or a spread like
...options) in the call signature; change the example invocation of
broadcastQueryClient({ queryClient, broadcastChannel }) to include
onBroadcastError (or ...options) so it matches the expanded options in the
implementation and docs.
packages/query-broadcast-client-experimental/src/__tests__/index.test.ts (1)

139-161: ⚡ Quick win

Add rejection-path coverage for updated and removed events.

The new failure-path assertions currently validate only the 'added' event shape. Since safePost is used across outbound message types, add one rejection test each for 'updated' and 'removed' to lock the event contract and prevent branch-specific regressions.

🤖 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 139 - 161, Add rejection-path tests for the other message types:
duplicate the existing test that mocks mockPostMessage to reject (using the same
cloneError and mockRejectedValueOnce) and assert onBroadcastError is called with
that error and an event object whose type is 'updated' (triggered by updating an
existing query via queryClient.setQueryData on the same key) and another test
where type is 'removed' (triggered by removing the query via
queryClient.removeQueries or setting data to undefined). Use the same helpers
used in the current test: mockPostMessage, broadcastQueryClient({... ,
onBroadcastError }), and expect.objectContaining<BroadcastErrorEvent> to
validate queryHash and queryKey shape so the failure path of safePost is covered
for 'updated' and 'removed'.
🤖 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/index.ts`:
- Line 19: Outbound "'added'" broadcast messages omit the source query state;
update the message shape to include a state field and ensure creators populate
it so receivers (the inbound 'added' handler that reads state) get the correct
data. Specifically, extend the 'added' payload type to include state (alongside
queryHash and queryKey) and modify the places that construct/send the 'added'
message (the two sites flagged around the existing message constructors) to pass
the source query's state (e.g., use the query's dehydrated/state value when
building the message) so the emitter and receiver contracts match.

---

Outside diff comments:
In `@packages/query-broadcast-client-experimental/src/__tests__/index.test.ts`:
- Around line 163-204: Wrap the console.warn spy setup and assertions in a
try/finally so the spy is always restored even if an assertion fails;
specifically, in the two tests ("should warn in dev when postMessage fails and
onBroadcastError is not provided" and "should not warn in production when
postMessage fails") move the creation of warnSpy (vi.spyOn(console,
'warn').mockImplementation(() => {})) before the action, perform the
broadcastQueryClient(...) and queryClient.setQueryData(...) and the expect(...)
inside a try block, and call warnSpy.mockRestore() in the finally block to
ensure the spy is cleaned up; keep references to mockPostMessage,
broadcastQueryClient, queryClient, and warnSpy to locate the changes.

---

Nitpick comments:
In `@docs/framework/react/plugins/broadcastQueryClient.md`:
- Around line 35-37: Update the top-level API snippet for broadcastQueryClient
to show the new options surface by including the onBroadcastError option (or a
spread like ...options) in the call signature; change the example invocation of
broadcastQueryClient({ queryClient, broadcastChannel }) to include
onBroadcastError (or ...options) so it matches the expanded options in the
implementation and docs.

In `@packages/query-broadcast-client-experimental/src/__tests__/index.test.ts`:
- Around line 139-161: Add rejection-path tests for the other message types:
duplicate the existing test that mocks mockPostMessage to reject (using the same
cloneError and mockRejectedValueOnce) and assert onBroadcastError is called with
that error and an event object whose type is 'updated' (triggered by updating an
existing query via queryClient.setQueryData on the same key) and another test
where type is 'removed' (triggered by removing the query via
queryClient.removeQueries or setting data to undefined). Use the same helpers
used in the current test: mockPostMessage, broadcastQueryClient({... ,
onBroadcastError }), and expect.objectContaining<BroadcastErrorEvent> to
validate queryHash and queryKey shape so the failure path of safePost is covered
for 'updated' and 'removed'.
🪄 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: ac035baa-9615-4fe3-b1ba-af6c95bf3b11

📥 Commits

Reviewing files that changed from the base of the PR and between 5b53390 and ff17549.

📒 Files selected for processing (3)
  • docs/framework/react/plugins/broadcastQueryClient.md
  • packages/query-broadcast-client-experimental/src/__tests__/index.test.ts
  • packages/query-broadcast-client-experimental/src/index.ts

Comment thread packages/query-broadcast-client-experimental/src/index.ts Outdated
The receiver's onmessage handler reads state for 'added' events to call
query.setState(state) and queryCache.build(..., state), but the sender
was not including state in the outbound message. This caused receiving
tabs to apply undefined state for newly added queries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@n-satoshi061

Copy link
Copy Markdown
Contributor Author

Update summary

After reviewing other PRs addressing the same issue (#10569, #10770, #10697) and the CodeRabbit feedback, I've made additional improvements beyond the initial unhandled-rejection fix. Here's a full summary of what's in the branch now.


Changes from the original PR

Types

  • Exported BroadcastErrorEvent interface (with queryKey: QueryKey) so consumers can import and reference the type directly:
    import type { BroadcastErrorEvent } from '@tanstack/query-broadcast-client-experimental'
  • Replaced the flat BroadcastMessage interface with a discriminated union — state is now only present on updated and added (not removed), which matches the actual wire protocol
  • onBroadcastError now receives BroadcastErrorEvent (no internal state field) rather than the raw message object
  • onBroadcastError return type is void | Promise<void> to explicitly support async callbacks

Error handling in safePost

Case Behaviour
postMessage rejects onBroadcastError called with error + event; or dev console.warn if no callback
onBroadcastError throws synchronously Caught by try/catch; dev console.warn with callback error
onBroadcastError returns a rejecting Promise Caught by result?.catch(); dev console.warn with callback error
Production, no callback Silent — no unhandled rejection, no console noise

Warning messages

Two distinct messages to make debugging easier:

  • Broadcast failure (no callback): [broadcastQueryClient] Failed to broadcast "X" event for query Y. The query value could not be structured-cloned; cross-tab sync for this query was skipped.
  • Callback itself failed: [broadcastQueryClient] onBroadcastError threw while handling "X" for query Y.

Bug fix: state missing from 'added' broadcasts

The receiver's onmessage handler reads state for added events (query.setState(state) / queryCache.build(..., state)), but the sender was omitting it — causing receiving tabs to apply undefined state. Fixed by including state in the outbound added message and updating the discriminated union accordingly.

Other

  • Replaced queryClient.getQueryCache().subscribe() with queryCache.subscribe() (variable already in scope)
  • Added JSDoc to all BroadcastQueryClientOptions fields
  • Added "Handling broadcast errors" section to the docs with a Sentry example

CodeRabbit review responses

Comment Resolution
onBroadcastError throwing causes a new unhandled rejection Wrapped callback in try/catch; added test asserting zero unhandledRejection events when callback throws
Async onBroadcastError rejecting causes unhandled rejection Extended type to void | Promise<void>; added result?.catch() guard; added test for async rejection path
'added' broadcasts omit state, breaking receiver contract Added state to the added discriminated union variant and to the safePost call site; confirmed with existing tests

Test coverage (9 tests)

  • subscribe / unsubscribe (existing)
  • onBroadcastError called when postMessage rejects
  • No unhandled rejection when callback throws synchronously
  • Dev console.warn emitted when callback throws (asserts message contains "onBroadcastError threw while handling")
  • No unhandled rejection when async callback rejects
  • Dev console.warn emitted when async callback rejects
  • Dev console.warn emitted when no callback provided (asserts "cross-tab sync for this query was skipped")
  • No console.warn in production

@n-satoshi061

Copy link
Copy Markdown
Contributor Author

@coderabbitai resume

@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Reviews resumed.

…akage

If an assertion fails before mockRestore(), the console.warn spy would
leak into subsequent tests. Wrapping each spy block in try/finally
ensures restore always runs regardless of test outcome.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@n-satoshi061

Copy link
Copy Markdown
Contributor Author

Addressed in the latest commit (52d1762).

All four tests that call vi.spyOn(console, 'warn') now wrap their assertions in try/finally so mockRestore() is guaranteed to run even if an assertion fails — preventing the spy from leaking into subsequent tests.

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
  // ...test body and assertions...
} finally {
  warnSpy.mockRestore()
}

Applied to:

  • should warn in dev when onBroadcastError itself throws
  • should warn in dev when async onBroadcastError rejects
  • should warn in dev when postMessage fails and onBroadcastError is not provided
  • should not warn in production when postMessage fails

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@nx-cloud

nx-cloud Bot commented Aug 19, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 3e3623e

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 2m 50s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 40s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-20 08:23:16 UTC

@pkg-pr-new

pkg-pr-new Bot commented Aug 20, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-query-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-experimental@10771

@tanstack/eslint-plugin-query

npm i https://pkg.pr.new/@tanstack/eslint-plugin-query@10771

@tanstack/lit-query

npm i https://pkg.pr.new/@tanstack/lit-query@10771

@tanstack/preact-query

npm i https://pkg.pr.new/@tanstack/preact-query@10771

@tanstack/preact-query-devtools

npm i https://pkg.pr.new/@tanstack/preact-query-devtools@10771

@tanstack/preact-query-persist-client

npm i https://pkg.pr.new/@tanstack/preact-query-persist-client@10771

@tanstack/query-async-storage-persister

npm i https://pkg.pr.new/@tanstack/query-async-storage-persister@10771

@tanstack/query-broadcast-client-experimental

npm i https://pkg.pr.new/@tanstack/query-broadcast-client-experimental@10771

@tanstack/query-core

npm i https://pkg.pr.new/@tanstack/query-core@10771

@tanstack/query-devtools

npm i https://pkg.pr.new/@tanstack/query-devtools@10771

@tanstack/query-persist-client-core

npm i https://pkg.pr.new/@tanstack/query-persist-client-core@10771

@tanstack/query-sync-storage-persister

npm i https://pkg.pr.new/@tanstack/query-sync-storage-persister@10771

@tanstack/react-query

npm i https://pkg.pr.new/@tanstack/react-query@10771

@tanstack/react-query-devtools

npm i https://pkg.pr.new/@tanstack/react-query-devtools@10771

@tanstack/react-query-next-experimental

npm i https://pkg.pr.new/@tanstack/react-query-next-experimental@10771

@tanstack/react-query-persist-client

npm i https://pkg.pr.new/@tanstack/react-query-persist-client@10771

@tanstack/solid-query

npm i https://pkg.pr.new/@tanstack/solid-query@10771

@tanstack/solid-query-devtools

npm i https://pkg.pr.new/@tanstack/solid-query-devtools@10771

@tanstack/solid-query-persist-client

npm i https://pkg.pr.new/@tanstack/solid-query-persist-client@10771

@tanstack/svelte-query

npm i https://pkg.pr.new/@tanstack/svelte-query@10771

@tanstack/svelte-query-devtools

npm i https://pkg.pr.new/@tanstack/svelte-query-devtools@10771

@tanstack/svelte-query-persist-client

npm i https://pkg.pr.new/@tanstack/svelte-query-persist-client@10771

@tanstack/vue-query

npm i https://pkg.pr.new/@tanstack/vue-query@10771

@tanstack/vue-query-devtools

npm i https://pkg.pr.new/@tanstack/vue-query-devtools@10771

commit: 5f8172f

@TkDodo
TkDodo merged commit 3c79861 into TanStack:main Aug 20, 2026
9 checks passed
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