From 6960735556009dc4d7d55561ca11c88901ebc9ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 13 Oct 2025 19:51:05 +0200 Subject: [PATCH] fix(hydration): fix unhandled promise rejections --- .changeset/tall-banks-drop.md | 6 +++ packages/query-core/src/hydration.ts | 46 ++++++++++++------- .../src/__tests__/HydrationBoundary.test.tsx | 6 --- 3 files changed, 35 insertions(+), 23 deletions(-) create mode 100644 .changeset/tall-banks-drop.md diff --git a/.changeset/tall-banks-drop.md b/.changeset/tall-banks-drop.md new file mode 100644 index 0000000000..ac495fbfc5 --- /dev/null +++ b/.changeset/tall-banks-drop.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-query': patch +'@tanstack/query-core': patch +--- + +Avoid unhandled promise rejection errors during de/rehydration of pending queries. diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index 1361036d86..4b8c614677 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -1,4 +1,5 @@ import { tryResolveSync } from './thenable' +import { noop } from './utils' import type { DefaultError, MutationKey, @@ -78,6 +79,26 @@ function dehydrateQuery( serializeData: TransformerFn, shouldRedactErrors: (error: unknown) => boolean, ): DehydratedQuery { + const promise = query.promise?.then(serializeData).catch((error) => { + if (!shouldRedactErrors(error)) { + // Reject original error if it should not be redacted + return Promise.reject(error) + } + // If not in production, log original error before rejecting redacted error + if (process.env.NODE_ENV !== 'production') { + console.error( + `A query that was dehydrated as pending ended up rejecting. [${query.queryHash}]: ${error}; The error will be redacted in production builds`, + ) + } + return Promise.reject(new Error('redacted')) + }) + + // Avoid unhandled promise rejections + // We need the promise we dehydrate to reject to get the correct result into + // the query cache, but we also want to avoid unhandled promise rejections + // in whatever environment the prefetches are happening in. + promise?.catch(noop) + return { dehydratedAt: Date.now(), state: { @@ -89,19 +110,7 @@ function dehydrateQuery( queryKey: query.queryKey, queryHash: query.queryHash, ...(query.state.status === 'pending' && { - promise: query.promise?.then(serializeData).catch((error) => { - if (!shouldRedactErrors(error)) { - // Reject original error if it should not be redacted - return Promise.reject(error) - } - // If not in production, log original error before rejecting redacted error - if (process.env.NODE_ENV !== 'production') { - console.error( - `A query that was dehydrated as pending ended up rejecting. [${query.queryHash}]: ${error}; The error will be redacted in production builds`, - ) - } - return Promise.reject(new Error('redacted')) - }), + promise, }), ...(query.meta && { meta: query.meta }), } @@ -259,10 +268,13 @@ export function hydrate( // which will re-use the passed `initialPromise` // Note that we need to call these even when data was synchronously // available, as we still need to set up the retryer - void query.fetch(undefined, { - // RSC transformed promises are not thenable - initialPromise: Promise.resolve(promise).then(deserializeData), - }) + query + .fetch(undefined, { + // RSC transformed promises are not thenable + initialPromise: Promise.resolve(promise).then(deserializeData), + }) + // Avoid unhandled promise rejections + .catch(noop) } }, ) diff --git a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx index a703d0a8a7..06d778b2c3 100644 --- a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx +++ b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx @@ -451,11 +451,6 @@ describe('React hydration', () => { const dehydratedState = dehydrate(prefetchQueryClient) - function ignore() { - // Ignore redacted unhandled rejection - } - process.addListener('unhandledRejection', ignore) - // Mimic what React/our synchronous thenable does for already rejected promises // @ts-expect-error dehydratedState.queries[0].promise.status = 'failure' @@ -484,7 +479,6 @@ describe('React hydration', () => { await vi.advanceTimersByTimeAsync(21) expect(rendered.getByText('new')).toBeInTheDocument() - process.removeListener('unhandledRejection', ignore) hydrateSpy.mockRestore() prefetchQueryClient.clear() clientQueryClient.clear()