Skip to content

fix(query-broadcast-client-experimental): stop leaking postMessage rejections as unhandled errors#10569

Open
senutpal wants to merge 2 commits intoTanStack:mainfrom
senutpal:fix/broadcast-postmessage-rejections
Open

fix(query-broadcast-client-experimental): stop leaking postMessage rejections as unhandled errors#10569
senutpal wants to merge 2 commits intoTanStack:mainfrom
senutpal:fix/broadcast-postmessage-rejections

Conversation

@senutpal
Copy link
Copy Markdown

@senutpal senutpal commented Apr 24, 2026

changes

fixes #10543 (also closes duplicate #10542).

BroadcastChannel.postMessage returns a Promise<void> that rejects when the payload cannot be structured-cloned - for example when a query's state.data, state.error, or queryKey contains a ReadableStream, File, Request/Response, function, or framework proxy (vue reactive, mobx observables). the three channel.postMessage(...) call sites inside the queryCache.subscribe callback never attached a .catch handler, so every non-cloneable update escaped as an unhandledrejection with a stack trace pointing only into node_modules/broadcast-channel/... and no queryHash context.

the fix wraps all three call sites in a single safePost helper that:

  • attaches a .catch so the rejection never bubbles as an unhandledrejection, even when a user-supplied onBroadcastError itself throws (the hook call is wrapped in try/catch and the handler error is logged in dev).
  • routes the failure with { type, queryHash, queryKey } metadata to a new optional onBroadcastError hook on BroadcastQueryClientOptions, for consumers who want to forward to sentry/datadog.
  • falls back to a development-only console.warn when no hook is provided, so cross-tab sync failures are never entirely silent.

the wire format of the broadcast messages is unchanged, so tabs running old and new code stay compatible during a roll-out. related: #8356 was closed with a user-side toRaw() workaround that does not generalize to ReadableStream / File / function payloads; this change supersedes that guidance at the library level.

checklist

  • i have followed the steps in the contributing guide.
  • i have tested this locally - `test:lib`, `test:types`, `test:eslint`, `test:build`, and `test:knip` all pass for the affected package.

release impact

  • this change affects published code, and i have generated a changeset.
  • this change is docs/ci/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes

    • Broadcast message failures (e.g., non-cloneable query values) no longer surface as unhandled errors; affected queries skip cross-tab sync while others continue.
  • New Features

    • Optional onBroadcastError callback to handle per-query broadcast failures; if absent, a development-time console warning is emitted.
  • Documentation

    • Added guidance on handling broadcast errors and an example wiring errors to a tracker.
  • Tests

    • Added tests covering broadcast failure handling and dev-time warnings.

@github-actions github-actions Bot added documentation Improvements or additions to documentation package: query-broadcast-client-experimental labels Apr 24, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 2026

📝 Walkthrough

Walkthrough

Adds guarded broadcasting: BroadcastChannel.postMessage calls are wrapped in a safePost helper that catches structured‑clone/postMessage rejections and forwards them to an optional onBroadcastError hook or emits a development-time console.warn, preventing unhandled rejections and skipping sync for the failing query.

Changes

Cohort / File(s) Summary
Changeset metadata
​.changeset/fix-broadcast-client-postmessage.md
Adds a changeset describing a patch fix: broadcast postMessage failures handled via onBroadcastError or dev console.warn.
Docs
docs/framework/react/plugins/broadcastQueryClient.md
Adds onBroadcastError and BroadcastErrorEvent docs; new “Handling broadcast errors” section and example wiring (Sentry).
Broadcast client implementation
packages/query-broadcast-client-experimental/src/index.ts
Introduces safePost wrapper, catches postMessage rejections, emits BroadcastErrorEvent, adds onBroadcastError option and exports BroadcastErrorEvent type; replaces direct postMessage calls with safePost.
Tests
packages/query-broadcast-client-experimental/src/__tests__/index.test.ts
Adds deterministic mock of BroadcastChannel, tests postMessage rejection handling, asserts onBroadcastError invocation and dev console.warn, and ensures no unhandledRejection even if the hook throws.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant App as Client (QueryClient)
  participant BC as BroadcastChannel
  participant Hook as onBroadcastError / Logger
  App->>BC: send broadcast message (safePost)
  alt postMessage resolves
    BC-->>App: resolved
  else postMessage rejects (DataCloneError)
    BC-->>App: rejection (error)
    App->>Hook: call onBroadcastError(error, BroadcastErrorEvent)
    alt hook throws or absent
      App->>Hook: console.warn (dev only) rgba(255,165,0,0.5)
    end
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A bunny hops across the wire,
Catches clones that would misfire.
If a message fails to fly,
An error hook or warning sigh—
Tabs stay synced, no unhandled cry. 🥕

🚥 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 accurately and concisely summarizes the main change: preventing unhandled promise rejections from BroadcastChannel.postMessage failures, which is the primary objective of this PR.
Description check ✅ Passed The description comprehensively covers the problem, solution, testing, and changeset generation, though the template sections use lowercase checkboxes instead of the specified uppercase format in the template.
Linked Issues check ✅ Passed The PR fully addresses issue #10543 by implementing the hookable approach: wrapping postMessage calls in safePost, adding onBroadcastError callback support, and emitting development-only console.warn messages with query context.
Out of Scope Changes check ✅ Passed All code changes are directly related to the linked issue: implementing error handling for postMessage rejections, adding the onBroadcastError hook, creating the BroadcastErrorEvent interface, updating documentation, and comprehensive test coverage.

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

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

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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)
docs/framework/react/plugins/broadcastQueryClient.md (1)

52-65: Optional: reference the exported BroadcastErrorEvent to avoid drift.

The inlined event shape duplicates the exported BroadcastErrorEvent interface from packages/query-broadcast-client-experimental/src/index.ts (lines 10–14). If the event type grows a field, this docs snippet will silently fall out of sync. Since the surrounding snippet already uses QueryKey without importing it, it's fine to keep it type-level and point at the exported name.

📚 Proposed tweak
-  /** Called when a query event cannot be broadcast to other tabs —
-   * most commonly because the query's `state.data`, `state.error`, or
-   * `queryKey` contains a value the structured-clone algorithm cannot
-   * serialize (e.g. `ReadableStream`, `File`, functions, framework
-   * proxies). Useful for routing failures to an error tracker. */
-  onBroadcastError?: (
-    error: unknown,
-    event: {
-      type: 'added' | 'removed' | 'updated'
-      queryHash: string
-      queryKey: QueryKey
-    },
-  ) => void
+  /** Called when a query event cannot be broadcast to other tabs —
+   * most commonly because the query's `state.data`, `state.error`, or
+   * `queryKey` contains a value the structured-clone algorithm cannot
+   * serialize (e.g. `ReadableStream`, `File`, functions, framework
+   * proxies). Useful for routing failures to an error tracker.
+   * See `BroadcastErrorEvent`. */
+  onBroadcastError?: (error: unknown, event: BroadcastErrorEvent) => void
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/framework/react/plugins/broadcastQueryClient.md` around lines 52 - 65,
The docs inline event type for onBroadcastError duplicates the exported
BroadcastErrorEvent interface and may drift; update the onBroadcastError
signature to reference the exported BroadcastErrorEvent (instead of the inline
object type) so it stays in sync with
packages/query-broadcast-client-experimental's BroadcastErrorEvent, keeping
QueryKey as-is and preserving the parameter order (error: unknown, event:
BroadcastErrorEvent) in the onBroadcastError declaration.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/query-broadcast-client-experimental/src/index.ts`:
- Around line 69-90: The .catch in safePost may reintroduce unhandled rejections
if onBroadcastError throws; modify the catch block in safePost to invoke
onBroadcastError inside a try/catch and swallow or locally log any error thrown
by the user handler so the promise chain never rejects due to a user callback
(keep the existing process.env.NODE_ENV console.warn behavior for
non-production); reference safePost, onBroadcastError, and the constructed event
object when locating the change. Also add a test variant to the existing "does
not surface broadcast failures as unhandled rejections" test that passes an
onBroadcastError which throws to assert no unhandled rejection occurs.

---

Nitpick comments:
In `@docs/framework/react/plugins/broadcastQueryClient.md`:
- Around line 52-65: The docs inline event type for onBroadcastError duplicates
the exported BroadcastErrorEvent interface and may drift; update the
onBroadcastError signature to reference the exported BroadcastErrorEvent
(instead of the inline object type) so it stays in sync with
packages/query-broadcast-client-experimental's BroadcastErrorEvent, keeping
QueryKey as-is and preserving the parameter order (error: unknown, event:
BroadcastErrorEvent) in the onBroadcastError declaration.
🪄 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: ed021583-1d1a-4711-b89f-212c355bdeb9

📥 Commits

Reviewing files that changed from the base of the PR and between 2f9527e and 2883756.

📒 Files selected for processing (4)
  • .changeset/fix-broadcast-client-postmessage.md
  • 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
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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

40-43: Optional: derive BroadcastMessage from BroadcastErrorEvent to avoid drift.

The three type/queryHash/queryKey fields are now duplicated between BroadcastMessage (internal wire shape) and the exported BroadcastErrorEvent. They must stay in sync because safePost constructs the latter from the former at lines 71–75. A small shared base would make future shape changes safer, but this is purely a nit on the current diff.

♻️ Possible shape
-type BroadcastMessage =
-  | { type: 'added'; queryHash: string; queryKey: QueryKey }
-  | { type: 'removed'; queryHash: string; queryKey: QueryKey }
-  | { type: 'updated'; queryHash: string; queryKey: QueryKey; state: unknown }
+type BroadcastMessage =
+  | ({ type: 'added' } & Omit<BroadcastErrorEvent, 'type'>)
+  | ({ type: 'removed' } & Omit<BroadcastErrorEvent, 'type'>)
+  | ({ type: 'updated'; state: unknown } & Omit<BroadcastErrorEvent, 'type'>)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/query-broadcast-client-experimental/src/index.ts` around lines 40 -
43, BroadcastMessage duplicates fields with the exported BroadcastErrorEvent;
unify them to avoid drift by extracting a shared base type (e.g., BroadcastBase
or reuse BroadcastErrorEvent's common fields) and have BroadcastMessage extend
that base (or derive BroadcastMessage from BroadcastErrorEvent) so the common
properties type/queryHash/queryKey are defined once; update the union variants
(types 'added'|'removed'|'updated') to extend the shared base and ensure
safePost (which constructs BroadcastErrorEvent from BroadcastMessage) still
type-checks against the new shared type.
packages/query-broadcast-client-experimental/src/__tests__/index.test.ts (1)

148-178: Tighten the assertion so the test distinguishes the two warn paths.

Today this test only asserts warn.toHaveBeenCalled(). Both the onBroadcastError threw branch (lines 86–89 in index.ts) and the no-hook fallback branch (lines 96–100) log through console.warn, so a regression that made the fallback fire while the hook was also wired would still pass this test. Assert on the specific "onBroadcastError threw" message so the test actually proves the guarded branch ran.

Also, await new Promise(r => setTimeout(r, 20)) here and at lines 140–141 is a timing gamble — vi.waitFor(() => expect(warn).toHaveBeenCalled()) (for this test) and waiting on postMessage call count (for the previous one) would be deterministic; then a single trailing microtask flush is enough before the onUnhandled assertion.

🧪 Proposed tweak
-        await new Promise((resolve) => setTimeout(resolve, 20))
-
-        expect(onUnhandled).not.toHaveBeenCalled()
-        expect(warn).toHaveBeenCalled()
+        await vi.waitFor(() => {
+          expect(warn).toHaveBeenCalled()
+        })
+        // Flush one more turn so any would-be unhandledRejection has fired.
+        await new Promise((resolve) => setImmediate(resolve))
+
+        expect(onUnhandled).not.toHaveBeenCalled()
+        expect(warn.mock.calls[0]?.[0]).toEqual(
+          expect.stringContaining('onBroadcastError threw'),
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/query-broadcast-client-experimental/src/__tests__/index.test.ts`
around lines 148 - 178, Tighten the test to assert the specific
"onBroadcastError threw" warning and make the wait deterministic: replace the
loose await new Promise(setTimeout) with vi.waitFor(() =>
expect(warn).toHaveBeenCalledWith(expect.stringContaining('onBroadcastError
threw'))) to ensure the guarded branch logged the specific message, then await
Promise.resolve() (a single microtask flush) before asserting on onUnhandled;
keep the existing console.warn spy and process.on/off setup and reference this
change in the test named 'does not surface failures even when onBroadcastError
itself throws' and the warn spy variable `warn`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/query-broadcast-client-experimental/src/__tests__/index.test.ts`:
- Around line 148-178: Tighten the test to assert the specific "onBroadcastError
threw" warning and make the wait deterministic: replace the loose await new
Promise(setTimeout) with vi.waitFor(() =>
expect(warn).toHaveBeenCalledWith(expect.stringContaining('onBroadcastError
threw'))) to ensure the guarded branch logged the specific message, then await
Promise.resolve() (a single microtask flush) before asserting on onUnhandled;
keep the existing console.warn spy and process.on/off setup and reference this
change in the test named 'does not surface failures even when onBroadcastError
itself throws' and the warn spy variable `warn`.

In `@packages/query-broadcast-client-experimental/src/index.ts`:
- Around line 40-43: BroadcastMessage duplicates fields with the exported
BroadcastErrorEvent; unify them to avoid drift by extracting a shared base type
(e.g., BroadcastBase or reuse BroadcastErrorEvent's common fields) and have
BroadcastMessage extend that base (or derive BroadcastMessage from
BroadcastErrorEvent) so the common properties type/queryHash/queryKey are
defined once; update the union variants (types 'added'|'removed'|'updated') to
extend the shared base and ensure safePost (which constructs BroadcastErrorEvent
from BroadcastMessage) still type-checks against the new shared type.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: eefd5dfc-a53a-4d1a-a8d2-d99555e36598

📥 Commits

Reviewing files that changed from the base of the PR and between 2883756 and cf838d6.

📒 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
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/framework/react/plugins/broadcastQueryClient.md

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

Labels

documentation Improvements or additions to documentation package: query-broadcast-client-experimental

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant