From 07e441c307fb788278499d305753506312cc55bb Mon Sep 17 00:00:00 2001 From: Max Schmidt <35741000+mooxl@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:26:58 +0000 Subject: [PATCH] fix(query-core): keep a placeholder that select maps to undefined Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a1c72d-7954-7320-aa9d-9f2611a997c4 --- .../keep-placeholder-select-undefined.md | 5 ++ .../src/__tests__/queryObserver.test.tsx | 37 ++++++++++++ packages/query-core/src/queryObserver.ts | 7 ++- .../src/__tests__/useQuery.test.tsx | 60 +++++++++++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 .changeset/keep-placeholder-select-undefined.md diff --git a/.changeset/keep-placeholder-select-undefined.md b/.changeset/keep-placeholder-select-undefined.md new file mode 100644 index 00000000000..f90c2a205b9 --- /dev/null +++ b/.changeset/keep-placeholder-select-undefined.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +Keep a memoized placeholder result when `select` maps it to `undefined`, so a disabled query with `keepPreviousData` no longer loops between `pending` and `success`. diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index 0c4bfd1f61b..c34a2099c8c 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryObserver, focusManager, + keepPreviousData, noop, timeoutManager, } from '..' @@ -2000,4 +2001,40 @@ describe('queryObserver', () => { unsubscribe2() }) }) + + it('should keep a placeholder that select maps to undefined', async () => { + const key = createKey() + const options = (id: string) => + queryClient.defaultQueryOptions({ + queryKey: [...key, id], + queryFn: () => [id], + enabled: id !== '', + placeholderData: keepPreviousData, + select: (items: Array) => items.find((item) => item === id), + }) + const observer = new QueryObserver(queryClient, options('a')) + const unsubscribe = observer.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + + // What useQuery does on every render: an optimistic result, then the options + for (let i = 0; i < 2; i++) { + expect(observer.getOptimisticResult(options('')).status).toBe('success') + observer.setOptions(options('')) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'success', + isPlaceholderData: true, + data: undefined, + }) + } + + // A new placeholderData is not memoized, so its undefined means pending + observer.setOptions({ ...options(''), placeholderData: () => undefined }) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + isPlaceholderData: false, + data: undefined, + }) + + unsubscribe() + }) }) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index fe47fa7e1c0..a7ccb1c7c10 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -648,10 +648,10 @@ export class QueryObserver< let placeholderData // Memoize placeholder data - if ( + const isMemoizedPlaceholder = prevResult?.isPlaceholderData && options.placeholderData === prevResultOptions?.placeholderData - ) { + if (isMemoizedPlaceholder) { placeholderData = prevResult.data // we have to skip select when reading this memoization // because prevResult.data is already "selected" @@ -669,7 +669,8 @@ export class QueryObserver< : options.placeholderData } - if (placeholderData !== undefined) { + // A memoized placeholder stays one even when `select` turned it into undefined + if (isMemoizedPlaceholder || placeholderData !== undefined) { status = 'success' data = replaceData( prevResult?.data, diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index d658d439cba..cd804c700b2 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -7130,4 +7130,64 @@ describe('useQuery', () => { expect(queryFn.mock.calls).toStrictEqual([['a'], ['b']]) }) + + it('should not loop when a query with select and keepPreviousData is disabled', async () => { + const key = createKey() + + function Page() { + const [id, setId] = React.useState('a') + const { data } = useQuery({ + queryKey: [...key, id], + queryFn: () => [id], + enabled: id !== '', + placeholderData: keepPreviousData, + select: (items) => items.find((item) => item === id), + }) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('picked: a')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button')) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('picked: none')).toBeInTheDocument() + }) + + it('should settle as placeholder data when select maps keepPreviousData to undefined', async () => { + const key = createKey() + const states: Array> = [] + + function Page() { + const [id, setId] = React.useState('a') + const state = useQuery({ + queryKey: [...key, id], + queryFn: () => [id], + enabled: id !== '', + placeholderData: keepPreviousData, + select: (items) => items.find((item) => item === id), + }) + states.push(state) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('status: success')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button')) + await vi.advanceTimersByTimeAsync(0) + expect(states.at(-1)).toMatchObject({ + status: 'success', + isPlaceholderData: true, + data: undefined, + }) + }) })