Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/keep-placeholder-select-undefined.md
Original file line number Diff line number Diff line change
@@ -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`.
37 changes: 37 additions & 0 deletions packages/query-core/src/__tests__/queryObserver.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
QueryClient,
QueryObserver,
focusManager,
keepPreviousData,
noop,
timeoutManager,
} from '..'
Expand Down Expand Up @@ -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<string>) => 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()
})
})
7 changes: 4 additions & 3 deletions packages/query-core/src/queryObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down
60 changes: 60 additions & 0 deletions packages/react-query/src/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<button onClick={() => setId('')}>{`picked: ${data ?? 'none'}`}</button>
)
}

const rendered = renderWithClient(queryClient, <Page />)
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<UseQueryResult<string | undefined>> = []

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 (
<button onClick={() => setId('')}>{`status: ${state.status}`}</button>
)
}

const rendered = renderWithClient(queryClient, <Page />)
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,
})
})
})