Skip to content

Conversation

SimonSimCity
Copy link
Contributor

@SimonSimCity SimonSimCity commented Sep 22, 2025

Fixes #9664, by catching the error of onSuccess, onError and onSettled callbacks passed to the mutate function and reporting it on a separate execution context.

This change will catch all errors and, by passing it to Promise.reject(e), move it to a new execution context, which we explicitly want to ignore (hence the void keyword).

By ignoring the error on the newly created execution context, this error is reported by https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event, where tools like Sentry can pick it up.

Raising of an unhandledRejection event is crucial to help the developer to be informed about their function misbehaving, and the surrounding try-catch will help securing this libraries code, despite of the fact that something failed in the users codebase.

Not to be confused with #9676, which handles a slightly different problem.

Summary by CodeRabbit

  • Bug Fixes

    • Lifecycle callback exceptions (success/error/settled) no longer disrupt mutation processing or observer notifications; such errors are isolated and surfaced asynchronously to avoid app crashes.
  • Tests

    • Added tests verifying that thrown errors from lifecycle callbacks are reported without preventing observer updates or multiple notification deliveries.

Copy link
Contributor

coderabbitai bot commented Sep 22, 2025

Walkthrough

Wraps mutation lifecycle callbacks (onSuccess/onError/onSettled) in try/catch and converts thrown errors into rejected Promises via void Promise.reject(err). Adds tests verifying thrown callback errors are reported as unhandled rejections and that subscribers are still notified twice.

Changes

Cohort / File(s) Summary
Mutation observer logic
packages/query-core/src/mutationObserver.ts
Wrap onSuccess/onError/onSettled calls in try/catch; if a callback throws, call void Promise.reject(err) to surface the error without interrupting the mutation notification flow; preserve subscription notifications.
Tests for erroneous callbacks
packages/query-core/src/__tests__/mutationObserver.test.tsx
Add tests that install a global unhandledRejection listener, trigger mutations whose lifecycle callbacks throw, assert two unhandled rejections are emitted and that the observer still notifies subscribers twice; includes listener setup/teardown.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant U as User Code
  participant MO as MutationObserver
  participant MF as mutationFn
  participant CB as Callbacks (onSuccess/onError/onSettled)
  participant LS as Subscribers
  participant PR as Promise.reject

  U->>MO: mutate(variables, { onSuccess/onError, onSettled })
  MO->>MF: execute mutationFn
  alt mutation succeeds
    MF-->>MO: result
    MO->>CB: try { onSuccess(result) }
    alt onSuccess throws
      CB-->>MO: throw e
      MO->>PR: void Promise.reject(e)
    end
    MO->>CB: try { onSettled(result, null) }
    alt onSettled throws
      CB-->>MO: throw e2
      MO->>PR: void Promise.reject(e2)
    end
  else mutation errors
    MF-->>MO: throw error
    MO->>CB: try { onError(error) }
    alt onError throws
      CB-->>MO: throw e
      MO->>PR: void Promise.reject(e)
    end
    MO->>CB: try { onSettled(undefined, error) }
    alt onSettled throws
      CB-->>MO: throw e2
      MO->>PR: void Promise.reject(e2)
    end
  end
  MO->>LS: notify subscribers (state updates)
  MO->>LS: notify subscribers (settled)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

A rabbit saw callbacks tumble and spree,
I caught each fall with a careful plea.
Rejected promises whisper the error,
Subscribers still get their double stirrer.
Hop on—state settles safe and free. 🐇✨

Pre-merge checks and finishing touches

✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The PR title succinctly describes the primary change: moving errors thrown by mutation callbacks to an asynchronous context for reporting. It directly reflects the mutationObserver.ts changes that wrap callback invocations and use void Promise.reject(e) to report callback errors without altering public APIs. The title is concise and relevant to the main change.
Linked Issues Check ✅ Passed The implementation matches the objectives of issue #9664 by catching errors in onSuccess/onError/onSettled and re-emitting them asynchronously via Promise.reject(e) so the library's settle/invalidation logic still runs; onSettled is invoked on both success and error paths and callback errors are surfaced as unhandled rejections. Unit tests added in mutationObserver.test.tsx assert that callback errors become unhandledRejection events while subscribers are still notified, which prevents a thrown callback from interrupting the settle flow that would leave isPending stuck. Based on the code changes and tests, the PR satisfies the coding-related requirements described in the linked issue.
Out of Scope Changes Check ✅ Passed The changes are limited to mutationObserver.ts and its tests, with no modifications to exported/public signatures or other packages, and the behavior changes are targeted solely at error handling for mutation callbacks as described in the PR objectives. No unrelated files or functionality appear to have been changed. Therefore there are no apparent out-of-scope changes.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 10e8102 and 7fbb7ac.

📒 Files selected for processing (1)
  • packages/query-core/src/__tests__/mutationObserver.test.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/query-core/src/tests/mutationObserver.test.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Test
  • GitHub Check: Preview

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

nx-cloud bot commented Sep 22, 2025

View your CI Pipeline Execution ↗ for commit 7fbb7ac

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

☁️ Nx Cloud last updated this comment at 2025-09-23 13:20:53 UTC

Copy link

pkg-pr-new bot commented Sep 22, 2025

More templates

@tanstack/angular-query-experimental

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

@tanstack/eslint-plugin-query

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

@tanstack/query-async-storage-persister

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

@tanstack/query-broadcast-client-experimental

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

@tanstack/query-core

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

@tanstack/query-devtools

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

@tanstack/query-persist-client-core

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

@tanstack/query-sync-storage-persister

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

@tanstack/react-query

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

@tanstack/react-query-devtools

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

@tanstack/react-query-next-experimental

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

@tanstack/react-query-persist-client

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

@tanstack/solid-query

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

@tanstack/solid-query-devtools

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

@tanstack/solid-query-persist-client

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

@tanstack/svelte-query

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

@tanstack/svelte-query-devtools

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

@tanstack/svelte-query-persist-client

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

@tanstack/vue-query

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

@tanstack/vue-query-devtools

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

commit: 7fbb7ac

Copy link

codecov bot commented Sep 22, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 60.70%. Comparing base (49243c8) to head (7fbb7ac).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #9675       +/-   ##
===========================================
+ Coverage   46.38%   60.70%   +14.32%     
===========================================
  Files         214      143       -71     
  Lines        8488     5734     -2754     
  Branches     1932     1546      -386     
===========================================
- Hits         3937     3481      -456     
+ Misses       4108     1953     -2155     
+ Partials      443      300      -143     
Components Coverage Δ
@tanstack/angular-query-experimental 93.85% <ø> (ø)
@tanstack/eslint-plugin-query ∅ <ø> (∅)
@tanstack/query-async-storage-persister 43.85% <ø> (ø)
@tanstack/query-broadcast-client-experimental 24.39% <ø> (ø)
@tanstack/query-codemods ∅ <ø> (∅)
@tanstack/query-core 97.49% <100.00%> (+0.01%) ⬆️
@tanstack/query-devtools 3.48% <ø> (ø)
@tanstack/query-persist-client-core 79.60% <ø> (ø)
@tanstack/query-sync-storage-persister 84.61% <ø> (ø)
@tanstack/query-test-utils ∅ <ø> (∅)
@tanstack/react-query 96.00% <ø> (ø)
@tanstack/react-query-devtools 10.00% <ø> (ø)
@tanstack/react-query-next-experimental ∅ <ø> (∅)
@tanstack/react-query-persist-client 100.00% <ø> (ø)
@tanstack/solid-query 78.06% <ø> (ø)
@tanstack/solid-query-devtools ∅ <ø> (∅)
@tanstack/solid-query-persist-client 100.00% <ø> (ø)
@tanstack/svelte-query 87.58% <ø> (ø)
@tanstack/svelte-query-devtools ∅ <ø> (∅)
@tanstack/svelte-query-persist-client 100.00% <ø> (ø)
@tanstack/vue-query 71.10% <ø> (ø)
@tanstack/vue-query-devtools ∅ <ø> (∅)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
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: 0

🧹 Nitpick comments (4)
packages/query-core/src/mutationObserver.ts (1)

174-196: DRY up callback handling and also surface async rejections from Promise-returning callbacks.

You can remove duplication and ensure callbacks that return a Promise and reject are also reported as unhandled rejections without awaiting them.

Apply this diff within #notify:

-          try {
-            this.#mutateOptions.onSuccess?.(
-              action.data,
-              variables,
-              onMutateResult,
-              context,
-            )
-          } catch (e) {
-            void Promise.reject(e)
-          }
-          try {
-            this.#mutateOptions.onSettled?.(
-              action.data,
-              null,
-              variables,
-              onMutateResult,
-              context,
-            )
-          } catch (e) {
-            void Promise.reject(e)
-          }
+          this.#callSafely(
+            this.#mutateOptions.onSuccess,
+            action.data,
+            variables,
+            onMutateResult,
+            context,
+          )
+          this.#callSafely(
+            this.#mutateOptions.onSettled,
+            action.data,
+            null,
+            variables,
+            onMutateResult,
+            context,
+          )
-          try {
-            this.#mutateOptions.onError?.(
-              action.error,
-              variables,
-              onMutateResult,
-              context,
-            )
-          } catch (e) {
-            void Promise.reject(e)
-          }
-          try {
-            this.#mutateOptions.onSettled?.(
-              undefined,
-              action.error,
-              variables,
-              onMutateResult,
-              context,
-            )
-          } catch (e) {
-            void Promise.reject(e)
-          }
+          this.#callSafely(
+            this.#mutateOptions.onError,
+            action.error,
+            variables,
+            onMutateResult,
+            context,
+          )
+          this.#callSafely(
+            this.#mutateOptions.onSettled,
+            undefined,
+            action.error,
+            variables,
+            onMutateResult,
+            context,
+          )

Add this private helper inside the class:

private #callSafely(
  cb: ((...args: any[]) => any) | undefined,
  ...args: any[]
): void {
  try {
    const r = cb?.(...args)
    if (r && typeof (r as any).then === 'function') {
      void (r as Promise<unknown>).catch((e) => Promise.reject(e))
    }
  } catch (e) {
    void Promise.reject(e)
  }
}

Also applies to: 197-217

packages/query-core/src/__tests__/mutationObserver.test.tsx (3)

388-390: Don’t remove all global listeners.

process.removeAllListeners('unhandledRejection') may interfere with the test runner or other suites. Prefer registering a per-test handler and removing just that handler.

Apply this diff:

-  describe('erroneous mutation callback', () => {
-    afterEach(() => {
-      process.removeAllListeners('unhandledRejection')
-    })
+  describe('erroneous mutation callback', () => {

And update each test to register and cleanup a dedicated handler (see next comment).


392-429: Register a dedicated handler and clean it up.

Avoid leaking listeners and keep tests isolated.

Apply this diff in the first test:

-      const unhandledRejectionFn = vi.fn()
-      process.on('unhandledRejection', (error) => unhandledRejectionFn(error))
+      const unhandledRejectionFn = vi.fn()
+      const handler = (error: unknown) => unhandledRejectionFn(error)
+      process.on('unhandledRejection', handler)
@@
-      unsubscribe()
+      unsubscribe()
+      process.off('unhandledRejection', handler)

Optionally, after advancing timers, also flush microtasks for robustness:

-      await vi.advanceTimersByTimeAsync(0)
+      await vi.advanceTimersByTimeAsync(0)
+      await Promise.resolve()

431-471: Mirror the handler pattern in the error-path test.

Same isolation as above.

Apply this diff:

-      const unhandledRejectionFn = vi.fn()
-      process.on('unhandledRejection', (error) => unhandledRejectionFn(error))
+      const unhandledRejectionFn = vi.fn()
+      const handler = (error: unknown) => unhandledRejectionFn(error)
+      process.on('unhandledRejection', handler)
@@
-      await vi.advanceTimersByTimeAsync(0)
+      await vi.advanceTimersByTimeAsync(0)
+      await Promise.resolve()
@@
-      unsubscribe()
+      unsubscribe()
+      process.off('unhandledRejection', handler)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fcd23c9 and 10e8102.

📒 Files selected for processing (2)
  • packages/query-core/src/__tests__/mutationObserver.test.tsx (1 hunks)
  • packages/query-core/src/mutationObserver.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/query-core/src/mutationObserver.ts (1)
packages/query-core/src/mutation.ts (1)
  • action (315-383)
packages/query-core/src/__tests__/mutationObserver.test.tsx (1)
packages/query-core/src/mutationObserver.ts (1)
  • MutationObserver (23-227)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Preview
  • GitHub Check: Test
🔇 Additional comments (3)
packages/query-core/src/mutationObserver.ts (1)

174-196: Bug fix is correct and aligns with PR objective.

Catching user-callback exceptions and deferring them via void Promise.reject(e) prevents aborting the batch and fixes the stuck isPending issue while still surfacing the error asynchronously. 👍

packages/query-core/src/__tests__/mutationObserver.test.tsx (2)

387-472: Nice coverage of thrown callback errors.

The tests validate both success and error paths and confirm listener notification continues. 👍


5-17: Minor: consider adding a jsdom/browser variant for unhandledrejection.

Optional: a test that uses window.addEventListener('unhandledrejection', ...) would exercise the browser path too.

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

Successfully merging this pull request may close these issues.

isPending remains true if error is thrown in onError of mutate and a query is invalidated on settle
1 participant