From e2b84b4f505db14eeb4c6cb04bc66804f95dffe5 Mon Sep 17 00:00:00 2001 From: Brennan Butler Date: Fri, 18 Sep 2026 11:33:37 +0700 Subject: [PATCH 1/2] Clear errors when query selectors are removed Drop obsolete selection errors when select is removed and invalidate the remembered selector so re-adding it runs selection and error handling again. Cover cached data under the same and a different key, and React option updates with and without Strict Mode. --- .changeset/removed-select-fix.md | 5 +++ .../src/__tests__/queryObserver.test.tsx | 28 +++++++++++++ packages/query-core/src/queryObserver.ts | 9 +++-- .../src/__tests__/useQuery.test.tsx | 40 ++++++++++++++++++- 4 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 .changeset/removed-select-fix.md diff --git a/.changeset/removed-select-fix.md b/.changeset/removed-select-fix.md new file mode 100644 index 00000000000..6af307e3003 --- /dev/null +++ b/.changeset/removed-select-fix.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-core": patch +--- + +Clear obsolete selection errors when the selector is removed, and recompute when it is re-added. diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index 594aa1a67da..08123eee2c8 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -24,6 +24,34 @@ describe('queryObserver', () => { vi.useRealTimers() }) + it.each([false, true])( + 'should clear a removed selector error with cached data (change key: %s)', + (changeKey) => { + const key = queryKey() + const nextKey = changeKey ? queryKey() : key + queryClient.setQueryData(key, 2) + queryClient.setQueryData(nextKey, 2) + const error = new Error('selection failed') + const select = (_value: number): number => { + throw error + } + const options = { queryKey: key, queryFn: () => 2, enabled: false } + const observer = new QueryObserver(queryClient, { ...options, select }) + expect(observer.getCurrentResult().error).toBe(error) + observer.setOptions({ ...options, queryKey: nextKey }) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'success', + data: 2, + error: null, + }) + observer.setOptions({ ...options, queryKey: nextKey, select }) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'error', + error, + }) + }, + ) + it('should trigger a fetch when subscribed', () => { const key = queryKey() const queryFn = vi diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index beb3a2370bc..1cdd2b466d5 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -671,10 +671,13 @@ export class QueryObserver< this.#selectError = selectError as TError } } - } else if (data === undefined) { - // a stored select error belongs to previously selected data; once that - // data is gone (query switch or reset), it must not leak into this result + } else if (data === undefined || !options.select) { + // Selection errors no longer apply when their data or selector is gone. this.#selectError = null + if (!options.select) { + // Re-adding the same selector must run it again, including error handling. + this.#selectFn = undefined + } } if (this.#selectError) { diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index 1c706a8ab21..9ec163aef03 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, fireEvent, render } from '@testing-library/react' +import { act, fireEvent, render, renderHook } from '@testing-library/react' import * as React from 'react' import { ErrorBoundary } from 'react-error-boundary' import { @@ -44,6 +44,44 @@ describe('useQuery', () => { vi.useRealTimers() }) + it.each([false, true])( + 'should recover when a throwing selector is removed (StrictMode: %s)', + (strict) => { + const key = queryKey() + queryClient.setQueryData(key, 2) + const error = new Error('selection failed') + const select = (_value: number): number => { + throw error + } + const view = renderHook( + ({ selected }) => + useQuery( + { + queryKey: key, + queryFn: () => 2, + enabled: false, + select: selected ? select : undefined, + }, + queryClient, + ), + { + initialProps: { selected: true }, + wrapper: strict ? React.StrictMode : undefined, + }, + ) + expect(view.result.current.error).toBe(error) + view.rerender({ selected: false }) + expect(view.result.current).toMatchObject({ + status: 'success', + data: 2, + error: null, + }) + view.rerender({ selected: true }) + expect(view.result.current).toMatchObject({ status: 'error', error }) + view.unmount() + }, + ) + // See https://github.com/tannerlinsley/react-query/issues/105 it('should allow to set default data value', async () => { const key = queryKey() From 15b08a0480604f83cc15666fecb1baedd19f2500 Mon Sep 17 00:00:00 2001 From: Brennan Butler Date: Fri, 18 Sep 2026 14:51:47 +0700 Subject: [PATCH 2/2] Recompute re-added placeholder selectors Invalidate selected placeholder data when its selector changes. Cover a removed and re-added selector that throws, including React Strict Mode. --- .../src/__tests__/queryObserver.test.tsx | 27 ++++++++++++ packages/query-core/src/queryObserver.ts | 3 +- .../src/__tests__/useQuery.test.tsx | 41 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index 08123eee2c8..78306af52e5 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -24,6 +24,33 @@ describe('queryObserver', () => { vi.useRealTimers() }) + it('should run a re-added placeholder selector and report its error', () => { + const key = queryKey() + const error = new Error('selection failed') + let shouldThrow = false + const select = (value: number) => { + if (shouldThrow) throw error + return value * 2 + } + const options = { + queryKey: key, + queryFn: () => 2, + enabled: false, + placeholderData: 2, + } + const observer = new QueryObserver(queryClient, { ...options, select }) + expect(observer.getCurrentResult().data).toBe(4) + observer.setOptions(options) + expect(observer.getCurrentResult().data).toBe(2) + shouldThrow = true + observer.setOptions({ ...options, select }) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'error', + error, + isPlaceholderData: false, + }) + }) + it.each([false, true])( 'should clear a removed selector error with cached data (change key: %s)', (changeKey) => { diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 1cdd2b466d5..06d29febbb2 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -620,7 +620,8 @@ export class QueryObserver< // Memoize placeholder data if ( prevResult?.isPlaceholderData && - options.placeholderData === prevResultOptions?.placeholderData + options.placeholderData === prevResultOptions?.placeholderData && + options.select === prevResultOptions.select ) { placeholderData = prevResult.data // we have to skip select when reading this memoization diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index 9ec163aef03..06a8ce08ec3 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -44,6 +44,47 @@ describe('useQuery', () => { vi.useRealTimers() }) + it.each([false, true])( + 'should report a re-added placeholder selector error (StrictMode: %s)', + (strict) => { + const key = queryKey() + const error = new Error('selection failed') + let shouldThrow = false + const select = (value: number) => { + if (shouldThrow) throw error + return value * 2 + } + const view = renderHook( + ({ selected }) => + useQuery( + { + queryKey: key, + queryFn: () => 2, + enabled: false, + placeholderData: 2, + select: selected ? select : undefined, + }, + queryClient, + ), + { + initialProps: { selected: true }, + wrapper: strict ? React.StrictMode : undefined, + }, + ) + expect(view.result.current.data).toBe(4) + view.rerender({ selected: false }) + expect(view.result.current.data).toBe(2) + shouldThrow = true + view.rerender({ selected: true }) + expect(view.result.current).toMatchObject({ + status: 'error', + error, + isPlaceholderData: false, + }) + view.unmount() + }, + ) + it.each([false, true])( 'should recover when a throwing selector is removed (StrictMode: %s)', (strict) => {