From 05ddd94bcbe311b6d70a20508ad7fe09cd4d909e Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 20 Aug 2026 12:19:07 +0200 Subject: [PATCH 1/6] ref: switch to use() --- .../QueryResetErrorBoundary.test.tsx | 52 +++-- .../src/__tests__/suspense.test.tsx | 93 +++++++-- .../usePrefetchInfiniteQuery.test.tsx | 32 ++- .../src/__tests__/usePrefetchQuery.test.tsx | 16 +- .../useSuspenseInfiniteQuery.test.tsx | 26 +-- .../src/__tests__/useSuspenseQueries.test.tsx | 183 +++++++++++------- .../src/__tests__/useSuspenseQuery.test.tsx | 179 ++++++++++++----- packages/react-query/src/__tests__/utils.tsx | 13 ++ packages/react-query/src/suspense.ts | 101 ++++++++-- packages/react-query/src/useBaseQuery.ts | 12 +- packages/react-query/src/useQueries.ts | 38 ++-- 11 files changed, 534 insertions(+), 211 deletions(-) diff --git a/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx b/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx index 21d594129fb..42dcd7d225f 100644 --- a/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx +++ b/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx @@ -12,7 +12,7 @@ import { useSuspenseQueries, useSuspenseQuery, } from '..' -import { renderWithClient } from './utils' +import { renderWithClient, renderWithClientAndSuspense } from './utils' describe('QueryErrorResetBoundary', () => { let queryCache: QueryCache @@ -53,7 +53,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, {({ reset }) => ( @@ -84,7 +84,9 @@ describe('QueryErrorResetBoundary', () => { succeed = true - fireEvent.click(rendered.getByText('retry')) + await act(async () => { + fireEvent.click(rendered.getByText('retry')) + }) await vi.advanceTimersByTimeAsync(11) expect(rendered.getByText('data')).toBeInTheDocument() @@ -120,7 +122,7 @@ describe('QueryErrorResetBoundary', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, {({ reset }) => ( @@ -151,7 +153,9 @@ describe('QueryErrorResetBoundary', () => { succeed = true - fireEvent.click(rendered.getByText('retry')) + await act(async () => { + fireEvent.click(rendered.getByText('retry')) + }) await vi.advanceTimersByTimeAsync(11) expect(rendered.getByText('status: error')).toBeInTheDocument() @@ -188,7 +192,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, {({ reset }) => ( @@ -219,7 +223,9 @@ describe('QueryErrorResetBoundary', () => { succeed = true - fireEvent.click(rendered.getByText('retry')) + await act(async () => { + fireEvent.click(rendered.getByText('retry')) + }) await vi.advanceTimersByTimeAsync(11) expect(rendered.getByText('data')).toBeInTheDocument() @@ -254,7 +260,7 @@ describe('QueryErrorResetBoundary', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, {({ reset }) => ( @@ -318,7 +324,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, {() => ( @@ -380,7 +386,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, {({ reset }) => ( @@ -444,7 +450,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, {({ reset }) => ( @@ -524,7 +530,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, {({ reset }) => ( @@ -593,7 +599,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, {({ reset }) => ( @@ -625,13 +631,17 @@ describe('QueryErrorResetBoundary', () => { expect(rendered.getByText('error boundary')).toBeInTheDocument() expect(rendered.getByText('retry')).toBeInTheDocument() - fireEvent.click(rendered.getByText('retry')) + await act(async () => { + fireEvent.click(rendered.getByText('retry')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('error boundary')).toBeInTheDocument() expect(rendered.getByText('retry')).toBeInTheDocument() - fireEvent.click(rendered.getByText('retry')) + await act(async () => { + fireEvent.click(rendered.getByText('retry')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data')).toBeInTheDocument() @@ -691,7 +701,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, {({ reset }) => ( @@ -722,7 +732,9 @@ describe('QueryErrorResetBoundary', () => { succeed = true - fireEvent.click(rendered.getByText('retry')) + await act(async () => { + fireEvent.click(rendered.getByText('retry')) + }) await vi.advanceTimersByTimeAsync(11) expect(rendered.getByText('data')).toBeInTheDocument() @@ -902,7 +914,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, {({ reset }) => ( @@ -936,7 +948,9 @@ describe('QueryErrorResetBoundary', () => { succeed = true - fireEvent.click(rendered.getByText('retry')) + await act(async () => { + fireEvent.click(rendered.getByText('retry')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data')).toBeInTheDocument() diff --git a/packages/react-query/src/__tests__/suspense.test.tsx b/packages/react-query/src/__tests__/suspense.test.tsx index edae5e6031c..fbbc924ac8c 100644 --- a/packages/react-query/src/__tests__/suspense.test.tsx +++ b/packages/react-query/src/__tests__/suspense.test.tsx @@ -1,16 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, render } from '@testing-library/react' +import { act } from '@testing-library/react' import { Suspense } from 'react' +import { QueryObserver } from '@tanstack/query-core' import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryClient, QueryClientProvider, useSuspenseQuery } from '..' +import { fallbackUse, getSuspensePromise } from '../suspense' +import { renderWithClientAndSuspense } from './utils' import type { StaleTime } from '@tanstack/query-core' import type { QueryKey } from '..' -function renderWithSuspense(client: QueryClient, ui: React.ReactNode) { - return render( - - {ui} - , +async function renderWithSuspense( + client: QueryClient, + ui: React.ReactNode, +): Promise> { + return renderWithClientAndSuspense( + client, + {ui}, ) } @@ -54,6 +59,68 @@ describe('Suspense Timer Tests', () => { vi.useRealTimers() }) + it('should reuse the suspense promise while a query is pending', async () => { + const key = queryKey() + const options = queryClient.defaultQueryOptions({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + suspense: true, + }) + const observer = new QueryObserver(queryClient, options) + const errorResetBoundary = { + clearReset: vi.fn(), + isReset: () => false, + reset: vi.fn(), + } + + const firstPromise = getSuspensePromise( + options, + observer, + errorResetBoundary, + ) + const secondPromise = getSuspensePromise( + options, + observer, + errorResetBoundary, + ) + + expect(secondPromise).toBe(firstPromise) + + await vi.advanceTimersByTimeAsync(10) + await firstPromise + }) + + it('should support pending, fulfilled, and rejected promise states in the React 18 fallback', async () => { + let resolvePending!: (value: string) => void + const pending = new Promise((resolve) => { + resolvePending = resolve + }) + + let thrown: unknown + try { + fallbackUse(pending) + } catch (error) { + thrown = error + } + expect(thrown).toBe(pending) + + resolvePending('data') + await pending + expect(fallbackUse(pending)).toBe('data') + + const error = new Error('error') + const rejected = Promise.reject(error) + try { + fallbackUse(rejected) + } catch (rejectedPromise) { + thrown = rejectedPromise + } + expect(thrown).toBe(rejected) + + await rejected.catch(() => undefined) + expect(() => fallbackUse(rejected)).toThrow(error) + }) + it('should enforce minimum staleTime of 1000ms when using suspense with number', async () => { const TestComponent = createTestQuery({ fetchCount, @@ -61,7 +128,7 @@ describe('Suspense Timer Tests', () => { staleTime: 10, }) - const rendered = renderWithSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -87,7 +154,7 @@ describe('Suspense Timer Tests', () => { staleTime: () => 10, }) - const rendered = renderWithSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -113,7 +180,7 @@ describe('Suspense Timer Tests', () => { staleTime: 2000, }) - const rendered = renderWithSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -139,7 +206,7 @@ describe('Suspense Timer Tests', () => { staleTime: undefined, }) - const rendered = renderWithSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -165,7 +232,7 @@ describe('Suspense Timer Tests', () => { staleTime: 'static', }) - const rendered = renderWithSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -191,7 +258,7 @@ describe('Suspense Timer Tests', () => { staleTime: () => 'static', }) - const rendered = renderWithSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -217,7 +284,7 @@ describe('Suspense Timer Tests', () => { staleTime: () => 3000, }) - const rendered = renderWithSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) diff --git a/packages/react-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx b/packages/react-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx index 7e3df99f010..b297764c6b1 100644 --- a/packages/react-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx +++ b/packages/react-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx @@ -8,7 +8,7 @@ import { usePrefetchInfiniteQuery, useSuspenseInfiniteQuery, } from '..' -import { renderWithClient } from './utils' +import { renderWithClientAndSuspense } from './utils' describe('usePrefetchInfiniteQuery', () => { let queryCache: QueryCache @@ -68,15 +68,19 @@ describe('usePrefetchInfiniteQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) await act(() => vi.advanceTimersByTimeAsync(30)) rendered.getByText('data: Do you fetch on render?') - fireEvent.click(rendered.getByText('Next Page')) + await act(async () => { + fireEvent.click(rendered.getByText('Next Page')) + }) expect( rendered.getByText('data: Or do you render as you fetch?'), ).toBeInTheDocument() - fireEvent.click(rendered.getByText('Next Page')) + await act(async () => { + fireEvent.click(rendered.getByText('Next Page')) + }) expect( rendered.getByText('data: Either way, Tanstack Query helps you!'), ).toBeInTheDocument() @@ -131,12 +135,16 @@ describe('usePrefetchInfiniteQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.getByText('data: Prefetch rocks!')).toBeInTheDocument() - fireEvent.click(rendered.getByText('Next Page')) + await act(async () => { + fireEvent.click(rendered.getByText('Next Page')) + }) expect(rendered.getByText('data: No waterfalls, boy!')).toBeInTheDocument() - fireEvent.click(rendered.getByText('Next Page')) + await act(async () => { + fireEvent.click(rendered.getByText('Next Page')) + }) expect(rendered.getByText('data: Tanstack Query #ftw')).toBeInTheDocument() expect(queryOpts.queryFn).not.toHaveBeenCalled() expect(Fallback).not.toHaveBeenCalled() @@ -187,14 +195,18 @@ describe('usePrefetchInfiniteQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) await act(() => vi.advanceTimersByTimeAsync(10)) rendered.getByText('data: Infinite Page 1') - fireEvent.click(rendered.getByText('Next Page')) + await act(async () => { + fireEvent.click(rendered.getByText('Next Page')) + }) await vi.advanceTimersByTimeAsync(11) expect(rendered.getByText('data: Infinite Page 2')).toBeInTheDocument() - fireEvent.click(rendered.getByText('Next Page')) + await act(async () => { + fireEvent.click(rendered.getByText('Next Page')) + }) await vi.advanceTimersByTimeAsync(11) expect(rendered.getByText('data: Infinite Page 3')).toBeInTheDocument() expect(queryOpts.queryFn).toHaveBeenCalledTimes(3) diff --git a/packages/react-query/src/__tests__/usePrefetchQuery.test.tsx b/packages/react-query/src/__tests__/usePrefetchQuery.test.tsx index 9b597d4d283..7480d4a8948 100644 --- a/packages/react-query/src/__tests__/usePrefetchQuery.test.tsx +++ b/packages/react-query/src/__tests__/usePrefetchQuery.test.tsx @@ -10,7 +10,7 @@ import { useQueryErrorResetBoundary, useSuspenseQuery, } from '..' -import { renderWithClient } from './utils' +import { renderWithClient, renderWithClientAndSuspense } from './utils' describe('usePrefetchQuery', () => { let queryCache: QueryCache @@ -53,7 +53,7 @@ describe('usePrefetchQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.getByText('Loading...')).toBeInTheDocument() @@ -88,7 +88,7 @@ describe('usePrefetchQuery', () => { queryClient.fetchQuery(queryOpts) await vi.advanceTimersByTimeAsync(10) queryOpts.queryFn.mockClear() - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.queryByText('fetching: true')).not.toBeInTheDocument() expect( @@ -133,7 +133,7 @@ describe('usePrefetchQuery', () => { queryClient.prefetchQuery(queryOpts) await vi.advanceTimersByTimeAsync(10) queryFn.mockClear() - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.getByText('Oops!')).toBeInTheDocument() expect(rendered.queryByText('data: Not an error')).not.toBeInTheDocument() @@ -170,7 +170,7 @@ describe('usePrefetchQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data: prefetchedQuery')).toBeInTheDocument() expect(queryOpts.queryFn).toHaveBeenCalledTimes(1) @@ -227,7 +227,9 @@ describe('usePrefetchQuery', () => { const rendered = renderWithClient(queryClient, ) expect(rendered.getByText('Oops!')).toBeInTheDocument() - fireEvent.click(rendered.getByText('Try again')) + await act(async () => { + fireEvent.click(rendered.getByText('Try again')) + }) await act(() => vi.advanceTimersByTimeAsync(10)) expect( rendered.getByText('data: This is fine :dog: :fire:'), @@ -297,7 +299,7 @@ describe('usePrefetchQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect( queryClient.getQueryState(firstQueryOpts.queryKey)?.fetchStatus, ).toBe('fetching') diff --git a/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx b/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx index f2116377fc4..51a96896382 100644 --- a/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx @@ -8,7 +8,7 @@ import { skipToken, useSuspenseInfiniteQuery, } from '..' -import { renderWithClient } from './utils' +import { renderWithClientAndSuspense } from './utils' import type { InfiniteData, UseSuspenseInfiniteQueryResult } from '..' describe('useSuspenseInfiniteQuery', () => { @@ -51,7 +51,7 @@ describe('useSuspenseInfiniteQuery', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, @@ -68,7 +68,9 @@ describe('useSuspenseInfiniteQuery', () => { status: 'success', }) - fireEvent.click(rendered.getByText('next')) + await act(async () => { + fireEvent.click(rendered.getByText('next')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data: 2')).toBeInTheDocument() @@ -80,7 +82,7 @@ describe('useSuspenseInfiniteQuery', () => { }) }) - it('should log an error when skipToken is passed as queryFn', () => { + it('should log an error when skipToken is passed as queryFn', async () => { const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}) @@ -107,7 +109,7 @@ describe('useSuspenseInfiniteQuery', () => { ) } - renderWithClient(queryClient, ) + await renderWithClientAndSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseInfiniteQuery', @@ -115,7 +117,7 @@ describe('useSuspenseInfiniteQuery', () => { consoleErrorSpy.mockRestore() }) - it('should log an error when skipToken is used in development environment', () => { + it('should log an error when skipToken is used in development environment', async () => { const envCopy = process.env.NODE_ENV process.env.NODE_ENV = 'development' @@ -135,7 +137,7 @@ describe('useSuspenseInfiniteQuery', () => { return null } - renderWithClient( + await renderWithClientAndSuspense( queryClient, @@ -150,7 +152,7 @@ describe('useSuspenseInfiniteQuery', () => { process.env.NODE_ENV = envCopy }) - it('should not log an error when skipToken is used in production environment', () => { + it('should not log an error when skipToken is used in production environment', async () => { const envCopy = process.env.NODE_ENV process.env.NODE_ENV = 'production' @@ -170,7 +172,7 @@ describe('useSuspenseInfiniteQuery', () => { return null } - renderWithClient( + await renderWithClientAndSuspense( queryClient, @@ -217,7 +219,7 @@ describe('useSuspenseInfiniteQuery', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClientWithPlaceholder, @@ -228,7 +230,9 @@ describe('useSuspenseInfiniteQuery', () => { await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data: 1')).toBeInTheDocument() - fireEvent.click(rendered.getByLabelText('toggle')) + await act(async () => { + fireEvent.click(rendered.getByLabelText('toggle')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data: 2')).toBeInTheDocument() diff --git a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx index eef3bdcc139..17bf7a7bb10 100644 --- a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx @@ -18,7 +18,7 @@ import { useSuspenseQueries, useSuspenseQuery, } from '..' -import { renderWithClient } from './utils' +import { renderWithClientAndSuspense } from './utils' import type { UseSuspenseQueryOptions } from '..' describe('useSuspenseQueries', () => { @@ -88,23 +88,53 @@ describe('useSuspenseQueries', () => { queryClient, ) - React.useEffect(() => { - onQueriesResolution(queriesResults) - }, [queriesResults]) - - return null + return
data: {queriesResults.join(',')}
} - render( + const rendered = await renderWithClientAndSuspense( + queryClient, }> , ) + expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(1000)) - expect(onQueriesResolution).toHaveBeenCalledTimes(1) - expect(onQueriesResolution).toHaveBeenLastCalledWith([1, 2]) + expect(rendered.getByText('data: 1,2')).toBeInTheDocument() + }) + + it('should start all suspense queries before suspending', async () => { + const queryFn1 = vi.fn(() => sleep(100).then(() => 1)) + const queryFn2 = vi.fn(() => sleep(200).then(() => 2)) + + function Page() { + const results = useSuspenseQueries({ + queries: [ + { queryKey: [1], queryFn: queryFn1 }, + { queryKey: [2], queryFn: queryFn2 }, + ], + }) + + return
data: {results.map((result) => result.data).join(',')}
+ } + + const rendered = await renderWithClientAndSuspense( + queryClient, + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + expect(queryFn1).toHaveBeenCalledTimes(1) + expect(queryFn2).toHaveBeenCalledTimes(1) + + await act(() => vi.advanceTimersByTimeAsync(100)) + expect(rendered.getByText('loading')).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(100)) + expect(rendered.getByText('data: 1,2')).toBeInTheDocument() }) it('should not suspend on mount if query has been already fetched', () => { @@ -161,30 +191,30 @@ describe('useSuspenseQueries', () => { queryClient, ) - React.useEffect(() => { - onQueriesResolution(queriesResults) - }, [queriesResults]) - - return null + return
data: {queriesResults.join(',')}
} - const { rerender } = render( + const rendered = await renderWithClientAndSuspense( + queryClient, }> , ) - rerender( - }> - - , - ) + expect(rendered.getByText('loading')).toBeInTheDocument() + + await act(async () => { + rendered.rerender( + }> + + , + ) + }) await act(() => vi.advanceTimersByTimeAsync(1000)) expect(onSuspend).toHaveBeenCalledTimes(1) - expect(onQueriesResolution).toHaveBeenCalledTimes(1) - expect(onQueriesResolution).toHaveBeenLastCalledWith([3, 4, 5, 6]) + expect(rendered.getByText('data: 3,4,5,6')).toBeInTheDocument() }) it('should suspend only once per queries change', async () => { @@ -210,14 +240,11 @@ describe('useSuspenseQueries', () => { queryClient, ) - React.useEffect(() => { - onQueriesResolution(queriesResults) - }, [queriesResults]) - - return null + return
data: {queriesResults.join(',')}
} - const { rerender } = render( + const rendered = await renderWithClientAndSuspense( + queryClient, }> , @@ -225,17 +252,21 @@ describe('useSuspenseQueries', () => { await act(() => vi.advanceTimersByTimeAsync(1000)) - rerender( - }> - - , - ) + expect(rendered.getByText('data: 1,2')).toBeInTheDocument() + await act(async () => { + rendered.rerender( + }> + + , + ) + }) + + expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(1000)) expect(onSuspend).toHaveBeenCalledTimes(2) - expect(onQueriesResolution).toHaveBeenCalledTimes(2) - expect(onQueriesResolution).toHaveBeenLastCalledWith([3, 4, 5, 6]) + expect(rendered.getByText('data: 3,4,5,6')).toBeInTheDocument() }) it('should only call combine after resolving', async () => { @@ -257,7 +288,7 @@ describe('useSuspenseQueries', () => { return

{data}

} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, @@ -312,7 +343,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient,
error boundary
}> @@ -328,9 +359,9 @@ describe('useSuspenseQueries', () => { shouldError = true - expect(() => { + await act(async () => { fireEvent.click(rendered.getByText('reset')) - }).not.toThrow() + }) await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('error boundary')).toBeInTheDocument() @@ -388,7 +419,7 @@ describe('useSuspenseQueries', () => { ) } - renderWithClient( + await renderWithClientAndSuspense( queryClient, }> @@ -452,7 +483,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, }> @@ -510,7 +541,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, }> @@ -550,7 +581,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, loading}> @@ -564,13 +595,17 @@ describe('useSuspenseQueries', () => { // go offline document.dispatchEvent(new CustomEvent('offline')) - fireEvent.click(rendered.getByText('fetch')) + await act(async () => { + fireEvent.click(rendered.getByText('fetch')) + }) expect(rendered.getByText('Data 0')).toBeInTheDocument() // go back online document.dispatchEvent(new CustomEvent('online')) - fireEvent.click(rendered.getByText('fetch')) + await act(async () => { + fireEvent.click(rendered.getByText('fetch')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) // query should resume @@ -603,7 +638,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient,
error boundary
}> @@ -616,7 +651,9 @@ describe('useSuspenseQueries', () => { await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('rendered: data')).toBeInTheDocument() - fireEvent.click(rendered.getByText('trigger fail')) + await act(async () => { + fireEvent.click(rendered.getByText('trigger fail')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('error boundary')).toBeInTheDocument() @@ -650,7 +687,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, @@ -661,7 +698,9 @@ describe('useSuspenseQueries', () => { await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data0')).toBeInTheDocument() - fireEvent.click(rendered.getByText('inc')) + await act(async () => { + fireEvent.click(rendered.getByText('inc')) + }) expect(rendered.getByText('pending')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data1')).toBeInTheDocument() @@ -705,7 +744,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, , @@ -715,7 +754,9 @@ describe('useSuspenseQueries', () => { await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data0')).toBeInTheDocument() - fireEvent.click(rendered.getByText('inc')) + await act(async () => { + fireEvent.click(rendered.getByText('inc')) + }) await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data1')).toBeInTheDocument() @@ -750,7 +791,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClientWithPlaceholder, @@ -761,7 +802,9 @@ describe('useSuspenseQueries', () => { await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data0')).toBeInTheDocument() - fireEvent.click(rendered.getByText('inc')) + await act(async () => { + fireEvent.click(rendered.getByText('inc')) + }) expect(rendered.getByText('pending')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data1')).toBeInTheDocument() @@ -803,7 +846,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -859,11 +902,13 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() - fireEvent.click(rendered.getByText('hide')) + await act(async () => { + fireEvent.click(rendered.getByText('hide')) + }) expect(rendered.getByText('page2')).toBeInTheDocument() // wait for query to be resolved await vi.advanceTimersByTimeAsync(3000) @@ -874,7 +919,7 @@ describe('useSuspenseQueries', () => { }) }) - it('should log an error when skipToken is passed as queryFn', () => { + it('should log an error when skipToken is passed as queryFn', async () => { const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}) @@ -903,7 +948,7 @@ describe('useSuspenseQueries', () => { ) } - renderWithClient(queryClient, ) + await renderWithClientAndSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseQueries', @@ -911,7 +956,7 @@ describe('useSuspenseQueries', () => { consoleErrorSpy.mockRestore() }) - it('should log an error when skipToken is used in development environment', () => { + it('should log an error when skipToken is used in development environment', async () => { const envCopy = process.env.NODE_ENV process.env.NODE_ENV = 'development' @@ -933,7 +978,7 @@ describe('useSuspenseQueries', () => { return null } - renderWithClient( + await renderWithClientAndSuspense( queryClient, @@ -947,7 +992,7 @@ describe('useSuspenseQueries', () => { process.env.NODE_ENV = envCopy }) - it('should not log an error when skipToken is used in production environment', () => { + it('should not log an error when skipToken is used in production environment', async () => { const envCopy = process.env.NODE_ENV process.env.NODE_ENV = 'production' @@ -969,7 +1014,7 @@ describe('useSuspenseQueries', () => { return null } - renderWithClient( + await renderWithClientAndSuspense( queryClient, @@ -1009,7 +1054,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, loading}> @@ -1062,7 +1107,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, loading}> @@ -1087,7 +1132,7 @@ describe('useSuspenseQueries', () => { expect(rendered.getByText('data2: data2')).toBeInTheDocument() }) - it('should not suspend and not refetch when all queries have fresh cached data', () => { + it('should not suspend and not refetch when all queries have fresh cached data', async () => { const key1 = queryKey() const key2 = queryKey() @@ -1119,7 +1164,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, loading}> @@ -1172,7 +1217,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, loading}> @@ -1240,7 +1285,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, loading}> @@ -1303,7 +1348,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, loading}> diff --git a/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx b/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx index 3c33abd9255..289810ea159 100644 --- a/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx @@ -13,7 +13,7 @@ import { useSuspenseInfiniteQuery, useSuspenseQuery, } from '..' -import { renderWithClient } from './utils' +import { renderWithClientAndSuspense } from './utils' import type { InfiniteData, UseSuspenseInfiniteQueryResult, @@ -62,7 +62,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, @@ -73,7 +73,7 @@ describe('useSuspenseQuery', () => { await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data: 1')).toBeInTheDocument() - fireEvent.click(rendered.getByLabelText('toggle')) + await act(() => fireEvent.click(rendered.getByLabelText('toggle'))) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data: 2')).toBeInTheDocument() @@ -84,6 +84,67 @@ describe('useSuspenseQuery', () => { expect(states[1]).toMatchObject({ data: 2, status: 'success' }) }) + it('should not create uncached promises for multiple queries in a transition', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Item({ id, count }: { id: number; count: number }) { + const { data } = useSuspenseQuery({ + queryKey: [key, id, count], + queryFn: () => sleep(10).then(() => `data-${id}-${count}`), + }) + + return
{data}
+ } + + function Page() { + const [count, setCount] = React.useState(0) + + return ( + <> + + + + + ) + } + + const rendered = await renderWithClientAndSuspense( + queryClient, + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data-1-0')).toBeInTheDocument() + expect(rendered.getByText('data-2-0')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(rendered.getByText('next')) + }) + await act(() => vi.advanceTimersByTimeAsync(10)) + + expect(rendered.getByText('data-1-1')).toBeInTheDocument() + expect(rendered.getByText('data-2-1')).toBeInTheDocument() + expect( + consoleMock.mock.calls.some((call) => + call.some( + (value) => + typeof value === 'string' && value.includes('uncached promise'), + ), + ), + ).toBe(false) + + consoleMock.mockRestore() + }) + it('should return the correct states for a successful infinite query', async () => { const key = queryKey() const states: Array>> = @@ -109,7 +170,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, @@ -126,7 +187,9 @@ describe('useSuspenseQuery', () => { status: 'success', }) - fireEvent.click(rendered.getByText('next')) + await act(async () => { + fireEvent.click(rendered.getByText('next')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data: 2')).toBeInTheDocument() @@ -149,7 +212,7 @@ describe('useSuspenseQuery', () => { return <>rendered } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, @@ -189,20 +252,24 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.queryByText('loading')).not.toBeInTheDocument() expect(rendered.queryByText('rendered')).not.toBeInTheDocument() expect(queryCache.find({ queryKey: key })).toBeUndefined() - fireEvent.click(rendered.getByLabelText('toggle')) + await act(async () => { + fireEvent.click(rendered.getByLabelText('toggle')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('rendered')).toBeInTheDocument() expect(queryCache.find({ queryKey: key })?.getObserversCount()).toBe(1) - fireEvent.click(rendered.getByLabelText('toggle')) + await act(async () => { + fireEvent.click(rendered.getByLabelText('toggle')) + }) expect(rendered.queryByText('loading')).not.toBeInTheDocument() await vi.advanceTimersByTimeAsync(10) expect(rendered.queryByText('rendered')).not.toBeInTheDocument() @@ -232,7 +299,7 @@ describe('useSuspenseQuery', () => { return
rendered
} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, {({ reset }) => ( @@ -265,7 +332,9 @@ describe('useSuspenseQuery', () => { expect(rendered.getByText('error boundary')).toBeInTheDocument() expect(rendered.getByText('retry')).toBeInTheDocument() - fireEvent.click(rendered.getByText('retry')) + await act(async () => { + fireEvent.click(rendered.getByText('retry')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('rendered')).toBeInTheDocument() @@ -298,7 +367,7 @@ describe('useSuspenseQuery', () => { return
rendered
} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, {({ reset }) => ( @@ -330,7 +399,9 @@ describe('useSuspenseQuery', () => { expect(rendered.getByText('error boundary')).toBeInTheDocument() expect(rendered.getByText('retry')).toBeInTheDocument() - fireEvent.click(rendered.getByText('retry')) + await act(async () => { + fireEvent.click(rendered.getByText('retry')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('error boundary')).toBeInTheDocument() @@ -338,7 +409,9 @@ describe('useSuspenseQuery', () => { succeed = true - fireEvent.click(rendered.getByText('retry')) + await act(async () => { + fireEvent.click(rendered.getByText('retry')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('rendered')).toBeInTheDocument() @@ -371,7 +444,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -414,13 +487,15 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText(`data: ${key1}`)).toBeInTheDocument() - fireEvent.click(rendered.getByText('switch')) + await act(async () => { + fireEvent.click(rendered.getByText('switch')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText(`data: ${key2}`)).toBeInTheDocument() @@ -472,14 +547,16 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('error boundary')).toBeInTheDocument() expect(rendered.getByText('retry')).toBeInTheDocument() - fireEvent.click(rendered.getByText('retry')) + await act(async () => { + fireEvent.click(rendered.getByText('retry')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('error boundary')).toBeInTheDocument() @@ -487,7 +564,9 @@ describe('useSuspenseQuery', () => { succeed = true - fireEvent.click(rendered.getByText('retry')) + await act(async () => { + fireEvent.click(rendered.getByText('retry')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('rendered')).toBeInTheDocument() @@ -527,7 +606,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -569,7 +648,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -622,7 +701,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) // render suspense fallback (loading) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -634,7 +713,9 @@ describe('useSuspenseQuery', () => { succeed = false // reset query -> and throw error - fireEvent.click(rendered.getByLabelText('fail')) + await act(async () => { + fireEvent.click(rendered.getByLabelText('fail')) + }) // render error boundary fallback (error boundary) await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('error boundary')).toBeInTheDocument() @@ -694,7 +775,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) // render suspense fallback (loading) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -706,7 +787,9 @@ describe('useSuspenseQuery', () => { succeed = false // change query key - fireEvent.click(rendered.getByLabelText('fail')) + await act(async () => { + fireEvent.click(rendered.getByLabelText('fail')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() // render error boundary fallback (error boundary) await act(() => vi.advanceTimersByTimeAsync(10)) @@ -742,7 +825,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, @@ -802,7 +885,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) // render suspense fallback (loading) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -814,7 +897,9 @@ describe('useSuspenseQuery', () => { succeed = false // refetch - fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + await act(async () => { + fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + }) // we are now in error state but still have data to show await vi.advanceTimersByTimeAsync(11) expect(rendered.getByText('rendered data error')).toBeInTheDocument() @@ -853,7 +938,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClientWithPlaceholder, @@ -864,13 +949,15 @@ describe('useSuspenseQuery', () => { await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data: 1')).toBeInTheDocument() - fireEvent.click(rendered.getByLabelText('toggle')) + await act(async () => { + fireEvent.click(rendered.getByLabelText('toggle')) + }) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data: 2')).toBeInTheDocument() }) - it('should log an error when skipToken is passed as queryFn', () => { + it('should log an error when skipToken is passed as queryFn', async () => { const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}) @@ -895,7 +982,7 @@ describe('useSuspenseQuery', () => { ) } - renderWithClient(queryClient, ) + await renderWithClientAndSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseQuery', @@ -917,7 +1004,7 @@ describe('useSuspenseQuery', () => { return
count: {state.data}
} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, @@ -935,7 +1022,7 @@ describe('useSuspenseQuery', () => { expect(count).toBeGreaterThanOrEqual(3) }) - it('should log an error when skipToken is used in development environment', () => { + it('should log an error when skipToken is used in development environment', async () => { const envCopy = process.env.NODE_ENV process.env.NODE_ENV = 'development' @@ -953,7 +1040,7 @@ describe('useSuspenseQuery', () => { return null } - renderWithClient( + await renderWithClientAndSuspense( queryClient, @@ -968,7 +1055,7 @@ describe('useSuspenseQuery', () => { process.env.NODE_ENV = envCopy }) - it('should not log an error when skipToken is used in production environment', () => { + it('should not log an error when skipToken is used in production environment', async () => { const envCopy = process.env.NODE_ENV process.env.NODE_ENV = 'production' @@ -986,7 +1073,7 @@ describe('useSuspenseQuery', () => { return null } - renderWithClient( + await renderWithClientAndSuspense( queryClient, @@ -1023,11 +1110,13 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() - fireEvent.click(rendered.getByText('set data')) + await act(async () => { + fireEvent.click(rendered.getByText('set data')) + }) await act(() => vi.advanceTimersByTimeAsync(0)) expect(rendered.getByText('data: manual data')).toBeInTheDocument() @@ -1057,7 +1146,7 @@ describe('useSuspenseQuery', () => { queryCache, }) - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( streamedClient, @@ -1083,7 +1172,7 @@ describe('useSuspenseQuery', () => { return
data: {data}
} - const rendered = renderWithClient( + const rendered = await renderWithClientAndSuspense( queryClient, @@ -1121,11 +1210,13 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithClientAndSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() - fireEvent.click(rendered.getByText('set undefined')) + await act(async () => { + fireEvent.click(rendered.getByText('set undefined')) + }) await act(() => vi.advanceTimersByTimeAsync(0)) // Suspense should NOT release — setQueryData(undefined) doesn't satisfy diff --git a/packages/react-query/src/__tests__/utils.tsx b/packages/react-query/src/__tests__/utils.tsx index ae8e2fb23f4..97eed91739e 100644 --- a/packages/react-query/src/__tests__/utils.tsx +++ b/packages/react-query/src/__tests__/utils.tsx @@ -22,6 +22,19 @@ export function renderWithClient( } as any } +export async function renderWithClientAndSuspense( + client: QueryClient, + ui: React.ReactElement, +): Promise> { + let rendered!: ReturnType + + await act(() => { + rendered = renderWithClient(client, ui) + }) + + return rendered +} + export function Blink({ duration, children, diff --git a/packages/react-query/src/suspense.ts b/packages/react-query/src/suspense.ts index b49e242ef55..2f11b5c1d8d 100644 --- a/packages/react-query/src/suspense.ts +++ b/packages/react-query/src/suspense.ts @@ -1,3 +1,4 @@ +import * as React from 'react' import type { DefaultError, DefaultedQueryObserverOptions, @@ -8,6 +9,56 @@ import type { } from '@tanstack/query-core' import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary' +type SuspenseThenable = Promise & { + status?: 'pending' | 'fulfilled' | 'rejected' + value?: T + reason?: unknown +} + +export const fallbackUse = (thenable: SuspenseThenable): T => { + switch (thenable.status) { + case 'pending': + throw thenable + case 'fulfilled': + return thenable.value as T + case 'rejected': + throw thenable.reason + default: + thenable.status = 'pending' + thenable.then( + (value) => { + thenable.status = 'fulfilled' + thenable.value = value + }, + (reason) => { + thenable.status = 'rejected' + thenable.reason = reason + }, + ) + throw thenable + } +} + +// React 18 does not have `use` +// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition +export const use = React.use || fallbackUse + +export const resolvedThenable = Promise.resolve( + undefined, +) as SuspenseThenable +resolvedThenable.status = 'fulfilled' +resolvedThenable.value = undefined + +type SuspensePromiseEntry = { + fetchPromise: Promise + promise: Promise +} + +const suspensePromiseCache = new WeakMap< + Query, + SuspensePromiseEntry +>() + export const defaultThrowOnError = < TQueryFnData = unknown, TError = DefaultError, @@ -53,23 +104,37 @@ export const shouldSuspend = ( result: QueryObserverResult, ) => defaultedOptions?.suspense && result.isPending -export const fetchOptimistic = < - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey extends QueryKey, ->( - defaultedOptions: DefaultedQueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - >, - observer: QueryObserver, +export function getSuspensePromise( + defaultedOptions: DefaultedQueryObserverOptions, + observer: QueryObserver, errorResetBoundary: QueryErrorResetBoundaryValue, -) => - observer.fetchOptimistic(defaultedOptions).catch(() => { - errorResetBoundary.clearReset() +): Promise { + const query = observer.getCurrentQuery() + const cached = suspensePromiseCache.get(query) + + if (cached) { + cached.fetchPromise.catch(() => errorResetBoundary.clearReset()) + return cached.promise + } + + const fetchPromise = observer.fetchOptimistic(defaultedOptions) + // The observer result is recalculated after React retries. We only use this + // promise to tell React when the fetch has settled. + const promise = fetchPromise.then( + () => undefined, + () => undefined, + ) + const entry = { fetchPromise, promise } + + suspensePromiseCache.set(query, entry) + + promise.then(() => { + if (suspensePromiseCache.get(query) === entry) { + suspensePromiseCache.delete(query) + } }) + + fetchPromise.catch(() => errorResetBoundary.clearReset()) + + return promise +} diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index 30beffc14b6..c0abd9a747f 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -12,8 +12,10 @@ import { import { useIsRestoring } from './IsRestoringProvider' import { ensureSuspenseTimers, - fetchOptimistic, + getSuspensePromise, + resolvedThenable, shouldSuspend, + use, } from './suspense' import type { QueryClient, @@ -119,8 +121,12 @@ export function useBaseQuery< }, [defaultedOptions, observer]) // Handle suspense - if (shouldSuspend(defaultedOptions, result)) { - throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary) + if (defaultedOptions.suspense) { + use( + shouldSuspend(defaultedOptions, result) + ? getSuspensePromise(defaultedOptions, observer, errorResetBoundary) + : resolvedThenable, + ) } // Handle error boundary diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index 5f5261a870e..f0862121eff 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -17,8 +17,10 @@ import { } from './errorBoundaryUtils' import { ensureSuspenseTimers, - fetchOptimistic, + getSuspensePromise, + resolvedThenable, shouldSuspend, + use, } from './suspense' import type { DefinedUseQueryResult, @@ -285,25 +287,27 @@ export function useQueries< ) }, [defaultedQueries, options, observer]) - const shouldAtLeastOneSuspend = optimisticResult.some((result, index) => - shouldSuspend(defaultedQueries[index], result), - ) + const suspensePromises = optimisticResult.map((result, index) => { + const opts = defaultedQueries[index] - const suspensePromises = shouldAtLeastOneSuspend - ? optimisticResult.flatMap((result, index) => { - const opts = defaultedQueries[index] + if (!opts?.suspense) { + return undefined + } - if (opts && shouldSuspend(opts, result)) { - const queryObserver = new QueryObserver(client, opts) - return fetchOptimistic(opts, queryObserver, errorResetBoundary) - } - return [] - }) - : [] + if (shouldSuspend(opts, result)) { + const queryObserver = new QueryObserver(client, opts) + return getSuspensePromise(opts, queryObserver, errorResetBoundary) + } - if (suspensePromises.length > 0) { - throw Promise.all(suspensePromises) - } + return resolvedThenable + }) + + // Start every fetch before calling use(), because use() suspends immediately. + suspensePromises.forEach((promise) => { + if (promise) { + use(promise) + } + }) const firstSingleResultWhichShouldThrow = optimisticResult.find( (result, index) => { const query = defaultedQueries[index] From 64e861fa984f896f06fef2407eaa5990e219f038 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 20 Aug 2026 12:30:28 +0200 Subject: [PATCH 2/6] add changeset --- .changeset/tasty-ears-study.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tasty-ears-study.md diff --git a/.changeset/tasty-ears-study.md b/.changeset/tasty-ears-study.md new file mode 100644 index 00000000000..187cd583c0f --- /dev/null +++ b/.changeset/tasty-ears-study.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-query': patch +--- + +react(suspense): switch from throwing promises to use() From c0c7462dfd71a94ccb1f224d8d10c6307e6b624a Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 20 Aug 2026 13:29:08 +0200 Subject: [PATCH 3/6] reorg test helpers --- .../QueryResetErrorBoundary.test.tsx | 32 ++-- .../src/__tests__/suspense.test.tsx | 12 +- .../usePrefetchInfiniteQuery.test.tsx | 8 +- .../src/__tests__/usePrefetchQuery.test.tsx | 12 +- .../useSuspenseInfiniteQuery.test.tsx | 31 +--- .../src/__tests__/useSuspenseQueries.test.tsx | 154 ++++++------------ .../src/__tests__/useSuspenseQuery.test.tsx | 114 ++++--------- packages/react-query/src/__tests__/utils.tsx | 10 +- 8 files changed, 121 insertions(+), 252 deletions(-) diff --git a/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx b/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx index 42dcd7d225f..d923cc1bb4d 100644 --- a/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx +++ b/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx @@ -12,7 +12,7 @@ import { useSuspenseQueries, useSuspenseQuery, } from '..' -import { renderWithClient, renderWithClientAndSuspense } from './utils' +import { renderWithClient, renderWithSuspense } from './utils' describe('QueryErrorResetBoundary', () => { let queryCache: QueryCache @@ -53,7 +53,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = await renderWithClientAndSuspense( + const rendered = renderWithClient( queryClient, {({ reset }) => ( @@ -122,7 +122,7 @@ describe('QueryErrorResetBoundary', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = renderWithClient( queryClient, {({ reset }) => ( @@ -192,7 +192,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = await renderWithClientAndSuspense( + const rendered = renderWithClient( queryClient, {({ reset }) => ( @@ -260,7 +260,7 @@ describe('QueryErrorResetBoundary', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = renderWithClient( queryClient, {({ reset }) => ( @@ -324,7 +324,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = await renderWithClientAndSuspense( + const rendered = renderWithClient( queryClient, {() => ( @@ -386,7 +386,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = await renderWithClientAndSuspense( + const rendered = renderWithClient( queryClient, {({ reset }) => ( @@ -450,7 +450,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = await renderWithClientAndSuspense( + const rendered = renderWithClient( queryClient, {({ reset }) => ( @@ -530,7 +530,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = await renderWithClientAndSuspense( + const rendered = renderWithClient( queryClient, {({ reset }) => ( @@ -599,7 +599,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, {({ reset }) => ( @@ -618,9 +618,7 @@ describe('QueryErrorResetBoundary', () => { )} > - loading}> - - +
)}
, @@ -701,7 +699,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = await renderWithClientAndSuspense( + const rendered = renderWithClient( queryClient, {({ reset }) => ( @@ -914,7 +912,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, {({ reset }) => ( @@ -933,9 +931,7 @@ describe('QueryErrorResetBoundary', () => { )} > - - - + )} , diff --git a/packages/react-query/src/__tests__/suspense.test.tsx b/packages/react-query/src/__tests__/suspense.test.tsx index fbbc924ac8c..7c2e7ae8aa8 100644 --- a/packages/react-query/src/__tests__/suspense.test.tsx +++ b/packages/react-query/src/__tests__/suspense.test.tsx @@ -5,20 +5,10 @@ import { QueryObserver } from '@tanstack/query-core' import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryClient, QueryClientProvider, useSuspenseQuery } from '..' import { fallbackUse, getSuspensePromise } from '../suspense' -import { renderWithClientAndSuspense } from './utils' +import { renderWithSuspense } from './utils' import type { StaleTime } from '@tanstack/query-core' import type { QueryKey } from '..' -async function renderWithSuspense( - client: QueryClient, - ui: React.ReactNode, -): Promise> { - return renderWithClientAndSuspense( - client, - {ui}, - ) -} - function createTestQuery(options: { fetchCount: { count: number } queryKey: QueryKey diff --git a/packages/react-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx b/packages/react-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx index b297764c6b1..ec4b9aba6a8 100644 --- a/packages/react-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx +++ b/packages/react-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx @@ -8,7 +8,7 @@ import { usePrefetchInfiniteQuery, useSuspenseInfiniteQuery, } from '..' -import { renderWithClientAndSuspense } from './utils' +import { renderWithSuspense } from './utils' describe('usePrefetchInfiniteQuery', () => { let queryCache: QueryCache @@ -68,7 +68,7 @@ describe('usePrefetchInfiniteQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) await act(() => vi.advanceTimersByTimeAsync(30)) rendered.getByText('data: Do you fetch on render?') @@ -135,7 +135,7 @@ describe('usePrefetchInfiniteQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('data: Prefetch rocks!')).toBeInTheDocument() await act(async () => { @@ -195,7 +195,7 @@ describe('usePrefetchInfiniteQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) await act(() => vi.advanceTimersByTimeAsync(10)) rendered.getByText('data: Infinite Page 1') diff --git a/packages/react-query/src/__tests__/usePrefetchQuery.test.tsx b/packages/react-query/src/__tests__/usePrefetchQuery.test.tsx index 7480d4a8948..522726f6478 100644 --- a/packages/react-query/src/__tests__/usePrefetchQuery.test.tsx +++ b/packages/react-query/src/__tests__/usePrefetchQuery.test.tsx @@ -10,7 +10,7 @@ import { useQueryErrorResetBoundary, useSuspenseQuery, } from '..' -import { renderWithClient, renderWithClientAndSuspense } from './utils' +import { renderWithClient, renderWithSuspense } from './utils' describe('usePrefetchQuery', () => { let queryCache: QueryCache @@ -53,7 +53,7 @@ describe('usePrefetchQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('Loading...')).toBeInTheDocument() @@ -88,7 +88,7 @@ describe('usePrefetchQuery', () => { queryClient.fetchQuery(queryOpts) await vi.advanceTimersByTimeAsync(10) queryOpts.queryFn.mockClear() - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.queryByText('fetching: true')).not.toBeInTheDocument() expect( @@ -133,7 +133,7 @@ describe('usePrefetchQuery', () => { queryClient.prefetchQuery(queryOpts) await vi.advanceTimersByTimeAsync(10) queryFn.mockClear() - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('Oops!')).toBeInTheDocument() expect(rendered.queryByText('data: Not an error')).not.toBeInTheDocument() @@ -170,7 +170,7 @@ describe('usePrefetchQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data: prefetchedQuery')).toBeInTheDocument() expect(queryOpts.queryFn).toHaveBeenCalledTimes(1) @@ -299,7 +299,7 @@ describe('usePrefetchQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect( queryClient.getQueryState(firstQueryOpts.queryKey)?.fetchStatus, ).toBe('fetching') diff --git a/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx b/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx index 51a96896382..8b0fac01295 100644 --- a/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx @@ -8,7 +8,7 @@ import { skipToken, useSuspenseInfiniteQuery, } from '..' -import { renderWithClientAndSuspense } from './utils' +import { renderWithSuspense } from './utils' import type { InfiniteData, UseSuspenseInfiniteQueryResult } from '..' describe('useSuspenseInfiniteQuery', () => { @@ -51,12 +51,7 @@ describe('useSuspenseInfiniteQuery', () => { ) } - const rendered = await renderWithClientAndSuspense( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -109,7 +104,7 @@ describe('useSuspenseInfiniteQuery', () => { ) } - await renderWithClientAndSuspense(queryClient, ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseInfiniteQuery', @@ -137,12 +132,7 @@ describe('useSuspenseInfiniteQuery', () => { return null } - await renderWithClientAndSuspense( - queryClient, - - - , - ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseInfiniteQuery', @@ -172,12 +162,7 @@ describe('useSuspenseInfiniteQuery', () => { return null } - await renderWithClientAndSuspense( - queryClient, - - - , - ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).not.toHaveBeenCalled() @@ -219,11 +204,9 @@ describe('useSuspenseInfiniteQuery', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClientWithPlaceholder, - - - , + , ) expect(rendered.getByText('loading')).toBeInTheDocument() diff --git a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx index 17bf7a7bb10..86bceb52ff3 100644 --- a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx @@ -18,7 +18,7 @@ import { useSuspenseQueries, useSuspenseQuery, } from '..' -import { renderWithClientAndSuspense } from './utils' +import { renderWithSuspense } from './utils' import type { UseSuspenseQueryOptions } from '..' describe('useSuspenseQueries', () => { @@ -91,11 +91,10 @@ describe('useSuspenseQueries', () => { return
data: {queriesResults.join(',')}
} - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, - }> - - , + , + , ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -119,12 +118,7 @@ describe('useSuspenseQueries', () => { return
data: {results.map((result) => result.data).join(',')}
} - const rendered = await renderWithClientAndSuspense( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() expect(queryFn1).toHaveBeenCalledTimes(1) @@ -194,11 +188,10 @@ describe('useSuspenseQueries', () => { return
data: {queriesResults.join(',')}
} - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, - }> - - , + , + , ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -243,11 +236,10 @@ describe('useSuspenseQueries', () => { return
data: {queriesResults.join(',')}
} - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, - }> - - , + , + , ) await act(() => vi.advanceTimersByTimeAsync(1000)) @@ -288,12 +280,7 @@ describe('useSuspenseQueries', () => { return

{data}

} - const rendered = await renderWithClientAndSuspense( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -343,12 +330,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient,
error boundary
}> - - - +
, ) @@ -419,12 +404,7 @@ describe('useSuspenseQueries', () => { ) } - await renderWithClientAndSuspense( - queryClient, - }> - - , - ) + await renderWithSuspense(queryClient, , ) await act(() => vi.advanceTimersByTimeAsync(localDuration)) @@ -483,11 +463,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, - }> - - , + , + , ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -541,11 +520,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, - }> - - , + , + , ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -581,11 +559,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, - loading}> - - , + , +
loading
, ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -638,12 +615,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient,
error boundary
}> - - - +
, ) @@ -687,12 +662,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -744,7 +714,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, , @@ -791,11 +761,9 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClientWithPlaceholder, - - - , + , ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -846,7 +814,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -902,7 +870,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -948,7 +916,7 @@ describe('useSuspenseQueries', () => { ) } - await renderWithClientAndSuspense(queryClient, ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseQueries', @@ -978,12 +946,7 @@ describe('useSuspenseQueries', () => { return null } - await renderWithClientAndSuspense( - queryClient, - - - , - ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseQueries', @@ -1014,12 +977,7 @@ describe('useSuspenseQueries', () => { return null } - await renderWithClientAndSuspense( - queryClient, - - - , - ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).not.toHaveBeenCalled() consoleErrorSpy.mockRestore() @@ -1054,11 +1012,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, - loading}> - - , + , +
loading
, ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -1107,11 +1064,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, - loading}> - - , + , +
loading
, ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -1164,11 +1120,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, - loading}> - - , + , +
loading
, ) // No suspend, fresh cached data shown immediately @@ -1217,11 +1172,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, - loading}> - - , + , +
loading
, ) // No suspend, cached data shown immediately @@ -1285,11 +1239,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, - loading}> - - , + , +
loading
, ) // No suspend, cached data shown immediately @@ -1348,11 +1301,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, - loading}> - - , + , +
loading
, ) // No suspend, stale cached data shown immediately with background refetch started diff --git a/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx b/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx index 289810ea159..fdac7c4e7cd 100644 --- a/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx @@ -13,7 +13,7 @@ import { useSuspenseInfiniteQuery, useSuspenseQuery, } from '..' -import { renderWithClientAndSuspense } from './utils' +import { renderWithSuspense } from './utils' import type { InfiniteData, UseSuspenseInfiniteQueryResult, @@ -62,12 +62,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -115,12 +110,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data-1-0')).toBeInTheDocument() @@ -170,12 +160,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -212,12 +197,7 @@ describe('useSuspenseQuery', () => { return <>rendered } - const rendered = await renderWithClientAndSuspense( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -252,7 +232,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.queryByText('loading')).not.toBeInTheDocument() expect(rendered.queryByText('rendered')).not.toBeInTheDocument() @@ -299,7 +279,7 @@ describe('useSuspenseQuery', () => { return
rendered
} - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, {({ reset }) => ( @@ -319,9 +299,7 @@ describe('useSuspenseQuery', () => { )} > - - - + )} , @@ -367,7 +345,7 @@ describe('useSuspenseQuery', () => { return
rendered
} - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClient, {({ reset }) => ( @@ -386,9 +364,7 @@ describe('useSuspenseQuery', () => { )} > - - - + )} , @@ -444,7 +420,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -487,7 +463,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -547,7 +523,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -606,7 +582,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -648,7 +624,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -701,7 +677,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) // render suspense fallback (loading) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -775,7 +751,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) // render suspense fallback (loading) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -825,12 +801,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -885,7 +856,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) // render suspense fallback (loading) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -938,11 +909,9 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense( + const rendered = await renderWithSuspense( queryClientWithPlaceholder, - - - , + , ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -982,7 +951,7 @@ describe('useSuspenseQuery', () => { ) } - await renderWithClientAndSuspense(queryClient, ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseQuery', @@ -1004,12 +973,7 @@ describe('useSuspenseQuery', () => { return
count: {state.data}
} - const rendered = await renderWithClientAndSuspense( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -1040,12 +1004,7 @@ describe('useSuspenseQuery', () => { return null } - await renderWithClientAndSuspense( - queryClient, - - - , - ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseQuery', @@ -1073,12 +1032,7 @@ describe('useSuspenseQuery', () => { return null } - await renderWithClientAndSuspense( - queryClient, - - - , - ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).not.toHaveBeenCalled() @@ -1110,7 +1064,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -1146,12 +1100,7 @@ describe('useSuspenseQuery', () => { queryCache, }) - const rendered = await renderWithClientAndSuspense( - streamedClient, - - - , - ) + const rendered = await renderWithSuspense(streamedClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -1172,12 +1121,7 @@ describe('useSuspenseQuery', () => { return
data: {data}
} - const rendered = await renderWithClientAndSuspense( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) await act(() => vi.advanceTimersByTimeAsync(0)) @@ -1210,7 +1154,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = await renderWithClientAndSuspense(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() diff --git a/packages/react-query/src/__tests__/utils.tsx b/packages/react-query/src/__tests__/utils.tsx index 97eed91739e..2146e4fd2eb 100644 --- a/packages/react-query/src/__tests__/utils.tsx +++ b/packages/react-query/src/__tests__/utils.tsx @@ -22,14 +22,18 @@ export function renderWithClient( } as any } -export async function renderWithClientAndSuspense( +export async function renderWithSuspense( client: QueryClient, - ui: React.ReactElement, + ui: React.ReactNode, + fallback: React.ReactNode = 'loading', ): Promise> { let rendered!: ReturnType await act(() => { - rendered = renderWithClient(client, ui) + rendered = renderWithClient( + client, + {ui}, + ) }) return rendered From 27bea1c0f36f72c99941ede6f34511892488f9a8 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 21 Aug 2026 13:45:14 +0200 Subject: [PATCH 4/6] fix: one stable thenable per query --- .../src/__tests__/suspense.test.tsx | 37 ++++++++++++++++ .../src/__tests__/useSuspenseQuery.test.tsx | 42 +++++++++++++++++++ packages/react-query/src/suspense.ts | 36 +++++++++------- packages/react-query/src/useBaseQuery.ts | 11 +++-- packages/react-query/src/useQueries.ts | 15 +++---- 5 files changed, 116 insertions(+), 25 deletions(-) diff --git a/packages/react-query/src/__tests__/suspense.test.tsx b/packages/react-query/src/__tests__/suspense.test.tsx index 7c2e7ae8aa8..4541763b942 100644 --- a/packages/react-query/src/__tests__/suspense.test.tsx +++ b/packages/react-query/src/__tests__/suspense.test.tsx @@ -67,11 +67,13 @@ describe('Suspense Timer Tests', () => { options, observer, errorResetBoundary, + observer.getCurrentQuery(), ) const secondPromise = getSuspensePromise( options, observer, errorResetBoundary, + observer.getCurrentQuery(), ) expect(secondPromise).toBe(firstPromise) @@ -80,6 +82,41 @@ describe('Suspense Timer Tests', () => { await firstPromise }) + it('should keep the suspense promise stable after it settles', async () => { + const key = queryKey() + const options = queryClient.defaultQueryOptions({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + suspense: true, + }) + const observer = new QueryObserver(queryClient, options) + const errorResetBoundary = { + clearReset: vi.fn(), + isReset: () => false, + reset: vi.fn(), + } + + const firstPromise = getSuspensePromise( + options, + observer, + errorResetBoundary, + observer.getCurrentQuery(), + ) + + await vi.advanceTimersByTimeAsync(10) + await firstPromise + + const secondPromise = getSuspensePromise( + options, + observer, + errorResetBoundary, + observer.getCurrentQuery(), + false, + ) + + expect(secondPromise).toBe(firstPromise) + }) + it('should support pending, fulfilled, and rejected promise states in the React 18 fallback', async () => { let resolvePending!: (value: string) => void const pending = new Promise((resolve) => { diff --git a/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx b/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx index fdac7c4e7cd..9645b0cc6fc 100644 --- a/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx @@ -135,6 +135,48 @@ describe('useSuspenseQuery', () => { consoleMock.mockRestore() }) + it('should not warn when a suspense promise settles before a replay', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Page() { + const [id, setId] = React.useState(0) + const { data } = useSuspenseQuery({ + queryKey: [key, id], + queryFn: () => Promise.resolve(id), + }) + + return ( + <> + +
data: {data}
+ + ) + } + + const rendered = await renderWithSuspense(queryClient, ) + + await act(async () => { + fireEvent.click(rendered.getByText('next')) + }) + + expect(rendered.getByText('data: 1')).toBeInTheDocument() + expect( + consoleMock.mock.calls.some((call) => + call.some( + (value) => + typeof value === 'string' && value.includes('uncached promise'), + ), + ), + ).toBe(false) + + consoleMock.mockRestore() + }) + it('should return the correct states for a successful infinite query', async () => { const key = queryKey() const states: Array>> = diff --git a/packages/react-query/src/suspense.ts b/packages/react-query/src/suspense.ts index 2f11b5c1d8d..bedb583b2e8 100644 --- a/packages/react-query/src/suspense.ts +++ b/packages/react-query/src/suspense.ts @@ -52,6 +52,7 @@ resolvedThenable.value = undefined type SuspensePromiseEntry = { fetchPromise: Promise promise: Promise + settled: boolean } const suspensePromiseCache = new WeakMap< @@ -108,33 +109,40 @@ export function getSuspensePromise( defaultedOptions: DefaultedQueryObserverOptions, observer: QueryObserver, errorResetBoundary: QueryErrorResetBoundaryValue, + query: Query, + shouldFetch = true, ): Promise { - const query = observer.getCurrentQuery() + const shouldFetchNow = + shouldFetch || + (query.state.status === 'error' && errorResetBoundary.isReset()) + const cached = suspensePromiseCache.get(query) - if (cached) { + if (cached && (!shouldFetchNow || !cached.settled)) { cached.fetchPromise.catch(() => errorResetBoundary.clearReset()) return cached.promise } + if (!shouldFetchNow) { + return resolvedThenable + } + const fetchPromise = observer.fetchOptimistic(defaultedOptions) // The observer result is recalculated after React retries. We only use this // promise to tell React when the fetch has settled. - const promise = fetchPromise.then( - () => undefined, - () => undefined, - ) - const entry = { fetchPromise, promise } + const entry = { + fetchPromise, + promise: undefined as unknown as Promise, + settled: false, + } + const settle = () => { + entry.settled = true + } + entry.promise = fetchPromise.then(settle, settle) suspensePromiseCache.set(query, entry) - promise.then(() => { - if (suspensePromiseCache.get(query) === entry) { - suspensePromiseCache.delete(query) - } - }) - fetchPromise.catch(() => errorResetBoundary.clearReset()) - return promise + return entry.promise } diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index c0abd9a747f..38b17640fff 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -13,7 +13,6 @@ import { useIsRestoring } from './IsRestoringProvider' import { ensureSuspenseTimers, getSuspensePromise, - resolvedThenable, shouldSuspend, use, } from './suspense' @@ -123,9 +122,13 @@ export function useBaseQuery< // Handle suspense if (defaultedOptions.suspense) { use( - shouldSuspend(defaultedOptions, result) - ? getSuspensePromise(defaultedOptions, observer, errorResetBoundary) - : resolvedThenable, + getSuspensePromise( + defaultedOptions, + observer, + errorResetBoundary, + client.getQueryCache().build(client, defaultedOptions), + shouldSuspend(defaultedOptions, result), + ), ) } diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index f0862121eff..c9a4860d8e0 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -18,7 +18,6 @@ import { import { ensureSuspenseTimers, getSuspensePromise, - resolvedThenable, shouldSuspend, use, } from './suspense' @@ -294,12 +293,14 @@ export function useQueries< return undefined } - if (shouldSuspend(opts, result)) { - const queryObserver = new QueryObserver(client, opts) - return getSuspensePromise(opts, queryObserver, errorResetBoundary) - } - - return resolvedThenable + const queryObserver = new QueryObserver(client, opts) + return getSuspensePromise( + opts, + queryObserver, + errorResetBoundary, + client.getQueryCache().build(client, opts), + shouldSuspend(opts, result), + ) }) // Start every fetch before calling use(), because use() suspends immediately. From 38b5cde31688574623f9ab1c55460a1ad52c8f4a Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 22 Aug 2026 10:50:43 +0200 Subject: [PATCH 5/6] fix: transition tests --- .../src/__tests__/transition.test.tsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/react-query/src/__tests__/transition.test.tsx b/packages/react-query/src/__tests__/transition.test.tsx index 14289f526ab..142ebab03ad 100644 --- a/packages/react-query/src/__tests__/transition.test.tsx +++ b/packages/react-query/src/__tests__/transition.test.tsx @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as React from 'react' import { act, fireEvent } from '@testing-library/react' import { queryKey, sleep } from '@tanstack/query-test-utils' -import { Suspense, startTransition, useDeferredValue } from 'react' +import { startTransition, useDeferredValue } from 'react' import { QueryClient, useSuspenseQuery } from '..' -import { renderWithClient } from './utils' +import { renderWithSuspense } from './utils' describe('react transitions', () => { let queryClient: QueryClient @@ -38,12 +38,7 @@ describe('react transitions', () => { ) } - const rendered = await renderWithClient( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -83,12 +78,7 @@ describe('react transitions', () => { ) } - const rendered = await renderWithClient( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() From 10a7583d2df6ee54f8ccb9b0569079319c9ea813 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 22 Aug 2026 18:16:33 +0200 Subject: [PATCH 6/6] feat: bring back placeholderData for suspense --- .changeset/suspense-placeholder-data.md | 5 + docs/framework/react/guides/suspense.md | 4 +- .../reference/useSuspenseInfiniteQuery.md | 4 +- .../react/reference/useSuspenseQueries.md | 6 +- .../react/reference/useSuspenseQuery.md | 2 - .../src/__tests__/transition.test.tsx | 228 +++++++++++++++++- .../useSuspenseInfiniteQuery.test-d.tsx | 39 ++- .../useSuspenseInfiniteQuery.test.tsx | 104 +++++++- .../__tests__/useSuspenseQueries.test-d.tsx | 31 +++ .../src/__tests__/useSuspenseQueries.test.tsx | 164 +++++++++++-- .../src/__tests__/useSuspenseQuery.test-d.tsx | 47 +++- .../src/__tests__/useSuspenseQuery.test.tsx | 172 ++++++++++++- packages/react-query/src/suspense.ts | 23 +- packages/react-query/src/types.ts | 23 +- packages/react-query/src/useBaseQuery.ts | 23 +- packages/react-query/src/useQueries.ts | 14 +- .../src/useSuspenseInfiniteQuery.ts | 4 +- .../react-query/src/useSuspenseQueries.ts | 52 ++-- packages/react-query/src/useSuspenseQuery.ts | 1 - 19 files changed, 860 insertions(+), 86 deletions(-) create mode 100644 .changeset/suspense-placeholder-data.md diff --git a/.changeset/suspense-placeholder-data.md b/.changeset/suspense-placeholder-data.md new file mode 100644 index 00000000000..e9b2a739c50 --- /dev/null +++ b/.changeset/suspense-placeholder-data.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-query': minor +--- + +Add placeholderData support to the React suspense query hooks. diff --git a/docs/framework/react/guides/suspense.md b/docs/framework/react/guides/suspense.md index 2c84c5ba308..8f3492cd419 100644 --- a/docs/framework/react/guides/suspense.md +++ b/docs/framework/react/guides/suspense.md @@ -25,7 +25,9 @@ This works nicely in TypeScript, because `data` is guaranteed to be defined (as On the flip side, you therefore can't conditionally enable / disable the Query. This generally shouldn't be necessary for dependent Queries because with suspense, all your Queries inside one component are fetched in serial. -`placeholderData` also doesn't exist for this Query. To prevent the UI from being replaced by a fallback during an update, wrap your updates that change the QueryKey into [startTransition](https://react.dev/reference/react/Suspense#preventing-unwanted-fallbacks). +`placeholderData` is supported. When it returns defined data, the Query renders that data in `success` state and fetches the full data in the background without showing the Suspense fallback. This is useful when a detail Query can show preview data from a cached list Query. + +You can also use `placeholderData: keepPreviousData` to keep data from the previous QueryKey. If keeping the currently rendered screen is your only goal, prefer wrapping updates that change the QueryKey in [startTransition](https://react.dev/reference/react/Suspense#preventing-unwanted-fallbacks) or using `useDeferredValue`. These React APIs keep the complete current screen in place and do not require placeholder data. ### throwOnError default diff --git a/docs/framework/react/reference/useSuspenseInfiniteQuery.md b/docs/framework/react/reference/useSuspenseInfiniteQuery.md index 6cedddaaed8..1cc020d77bd 100644 --- a/docs/framework/react/reference/useSuspenseInfiniteQuery.md +++ b/docs/framework/react/reference/useSuspenseInfiniteQuery.md @@ -13,17 +13,17 @@ The same as for [useInfiniteQuery](./useInfiniteQuery.md), except for: - `throwOnError` - `enabled` -- `placeholderData` **Returns** Same object as [useInfiniteQuery](./useInfiniteQuery.md), except that: - `data` is guaranteed to be defined -- `isPlaceholderData` is missing - `status` is either `success` or `error` - the derived flags are set accordingly. +When `placeholderData` is defined, the result has `isPlaceholderData: true` until the Query returns its data. + **Caveat** [Cancellation](../guides/query-cancellation.md) does not work. diff --git a/docs/framework/react/reference/useSuspenseQueries.md b/docs/framework/react/reference/useSuspenseQueries.md index 0b10231f0cd..d9c771088b4 100644 --- a/docs/framework/react/reference/useSuspenseQueries.md +++ b/docs/framework/react/reference/useSuspenseQueries.md @@ -13,7 +13,8 @@ The same as for [useQueries](./useQueries.md), except that each `query` can't ha - `throwOnError` - `enabled` -- `placeholderData` + +`placeholderData` is supported, but its callback does not receive information from previously rendered Queries. This matches `useQueries`, because the number and order of Queries can change between renders. > The [`select` typing caveat](./useQueries.md#typescript-typing-the-select-option) for `useQueries` applies here as well: annotate the `select` parameter or use the [`queryOptions`](./queryOptions.md) helper to keep type inference. @@ -22,10 +23,11 @@ The same as for [useQueries](./useQueries.md), except that each `query` can't ha Same structure as [useQueries](./useQueries.md), except that for each `query`: - `data` is guaranteed to be defined -- `isPlaceholderData` is missing - `status` is either `success` or `error` - the derived flags are set accordingly. +Each result includes `isPlaceholderData`. + **Caveats** Keep in mind that the component will only re-mount after **all queries** have finished loading. Hence, if a query has gone stale in the time it took for all the queries to complete, it will be fetched again at re-mount. To avoid this, make sure to set a high enough `staleTime`. diff --git a/docs/framework/react/reference/useSuspenseQuery.md b/docs/framework/react/reference/useSuspenseQuery.md index b531bca47d2..29e6f12d9d2 100644 --- a/docs/framework/react/reference/useSuspenseQuery.md +++ b/docs/framework/react/reference/useSuspenseQuery.md @@ -13,14 +13,12 @@ The same as for [useQuery](./useQuery.md), except for: - `throwOnError` - `enabled` -- `placeholderData` **Returns** Same object as [useQuery](./useQuery.md), except that: - `data` is guaranteed to be defined -- `isPlaceholderData` is missing - `status` is either `success` or `error` - the derived flags are set accordingly. diff --git a/packages/react-query/src/__tests__/transition.test.tsx b/packages/react-query/src/__tests__/transition.test.tsx index 142ebab03ad..00322d11b80 100644 --- a/packages/react-query/src/__tests__/transition.test.tsx +++ b/packages/react-query/src/__tests__/transition.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { act, fireEvent } from '@testing-library/react' import { queryKey, sleep } from '@tanstack/query-test-utils' import { startTransition, useDeferredValue } from 'react' -import { QueryClient, useSuspenseQuery } from '..' +import { QueryClient, keepPreviousData, useSuspenseQuery } from '..' import { renderWithSuspense } from './utils' describe('react transitions', () => { @@ -97,4 +97,230 @@ describe('react transitions', () => { expect(rendered.getByText('data: test1')).toBeInTheDocument() }) + + it.each([ + ['startTransition', 'static'], + ['startTransition', 'keepPreviousData'], + ['useDeferredValue', 'static'], + ['useDeferredValue', 'keepPreviousData'], + ] as const)( + 'should use %s with %s placeholderData', + async (transition, placeholderMode) => { + const key = queryKey() + const queryFn = vi.fn((count: number) => + sleep(10).then(() => `test${count}`), + ) + + function Page() { + const [count, setCount] = React.useState(0) + const deferredCount = useDeferredValue(count) + const queryCount = + transition === 'useDeferredValue' ? deferredCount : count + const query = useSuspenseQuery({ + queryKey: [key, queryCount], + queryFn: () => queryFn(queryCount), + placeholderData: + placeholderMode === 'static' ? 'placeholder' : keepPreviousData, + }) + + return ( +
+ +
data: {query.data}
+
placeholder: {String(query.isPlaceholderData)}
+
+ ) + } + + const rendered = await renderWithSuspense(queryClient, ) + + expect( + rendered.getByText( + placeholderMode === 'static' ? 'data: placeholder' : 'loading', + ), + ).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data: test0')).toBeInTheDocument() + expect(rendered.getByText('placeholder: false')).toBeInTheDocument() + + await act(() => + fireEvent.click(rendered.getByRole('button', { name: 'increment' })), + ) + + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect( + rendered.getByText( + placeholderMode === 'static' ? 'data: placeholder' : 'data: test0', + ), + ).toBeInTheDocument() + expect(rendered.getByText('placeholder: true')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(2) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data: test1')).toBeInTheDocument() + expect(rendered.getByText('placeholder: false')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(2) + }, + ) + + it.each([ + ['startTransition', 'static'], + ['startTransition', 'keepPreviousData'], + ['useDeferredValue', 'static'], + ['useDeferredValue', 'keepPreviousData'], + ] as const)( + 'should start a %s placeholder query with %s before a sibling suspends', + async (transition, placeholderMode) => { + const key = queryKey() + const placeholderQueryFn = vi.fn((count: number) => + sleep(10).then(() => `placeholder-query-${count}`), + ) + const suspenseQueryFn = vi.fn((count: number) => + sleep(10).then(() => `suspense-query-${count}`), + ) + + function PlaceholderQuery({ count }: { count: number }) { + const query = useSuspenseQuery({ + queryKey: [...key, 'placeholder', count], + queryFn: () => placeholderQueryFn(count), + placeholderData: + placeholderMode === 'static' ? 'placeholder' : keepPreviousData, + }) + return
{query.data}
+ } + + function SuspenseQuery({ count }: { count: number }) { + const query = useSuspenseQuery({ + queryKey: [...key, 'suspense', count], + queryFn: () => suspenseQueryFn(count), + }) + return
{query.data}
+ } + + function Page() { + const [count, setCount] = React.useState(0) + const deferredCount = useDeferredValue(count) + const queryCount = + transition === 'useDeferredValue' ? deferredCount : count + + return ( + <> + + + + + + + + + ) + } + + const rendered = await renderWithSuspense(queryClient, ) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('placeholder-query-0')).toBeInTheDocument() + expect(rendered.getByText('suspense-query-0')).toBeInTheDocument() + + await act(() => + fireEvent.click(rendered.getByRole('button', { name: 'increment' })), + ) + + expect(rendered.getByText('placeholder-query-0')).toBeInTheDocument() + expect(rendered.getByText('suspense-query-0')).toBeInTheDocument() + expect(placeholderQueryFn).toHaveBeenCalledTimes(2) + expect(suspenseQueryFn).toHaveBeenCalledTimes(2) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('placeholder-query-1')).toBeInTheDocument() + expect(rendered.getByText('suspense-query-1')).toBeInTheDocument() + expect(placeholderQueryFn).toHaveBeenCalledTimes(2) + }, + ) + + it('should fetch a placeholder query for an abandoned transition', async () => { + const key = queryKey() + const placeholderQueryFn = vi.fn((count: number) => + sleep(10).then(() => `placeholder-query-${count}`), + ) + const suspenseQueryFn = vi.fn((count: number) => + sleep(10).then(() => `suspense-query-${count}`), + ) + + queryClient.setQueryData([...key, 'placeholder', 0], 'placeholder-query-0') + queryClient.setQueryData([...key, 'suspense', 0], 'suspense-query-0') + + function Page({ hide }: { hide: () => void }) { + const [count, setCount] = React.useState(0) + const placeholderQuery = useSuspenseQuery({ + queryKey: [...key, 'placeholder', count], + queryFn: () => placeholderQueryFn(count), + placeholderData: keepPreviousData, + }) + const suspenseQuery = useSuspenseQuery({ + queryKey: [...key, 'suspense', count], + queryFn: () => suspenseQueryFn(count), + }) + + return ( + <> + + +
{placeholderQuery.data}
+
{suspenseQuery.data}
+ + ) + } + + function App() { + const [show, setShow] = React.useState(true) + return show ? setShow(false)} /> :
hidden
+ } + + const rendered = await renderWithSuspense(queryClient, ) + + expect(rendered.getByText('placeholder-query-0')).toBeInTheDocument() + expect(rendered.getByText('suspense-query-0')).toBeInTheDocument() + + await act(() => + fireEvent.click(rendered.getByRole('button', { name: 'increment' })), + ) + expect(placeholderQueryFn).toHaveBeenCalledWith(1) + expect(suspenseQueryFn).toHaveBeenCalledWith(1) + + await act(() => + fireEvent.click(rendered.getByRole('button', { name: 'hide' })), + ) + expect(rendered.getByText('hidden')).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(placeholderQueryFn).toHaveBeenCalledWith(1) + }) }) diff --git a/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx b/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx index c9063692068..e1a98a362bc 100644 --- a/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx +++ b/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx @@ -1,5 +1,5 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' -import { skipToken } from '@tanstack/query-core' +import { keepPreviousData, skipToken } from '@tanstack/query-core' import { queryKey } from '@tanstack/query-test-utils' import { useSuspenseInfiniteQuery } from '../useSuspenseInfiniteQuery' import type { InfiniteData } from '@tanstack/query-core' @@ -46,15 +46,44 @@ describe('useSuspenseInfiniteQuery', () => { expectTypeOf(status).toEqualTypeOf<'error' | 'success'>() }) - it('should not allow placeholderData, enabled or throwOnError props', () => { + it('should allow placeholderData', () => { + const query = useSuspenseInfiniteQuery({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(5), + initialPageParam: 1, + getNextPageParam: () => 1, + placeholderData: keepPreviousData, + }) + + expectTypeOf(query.data).toEqualTypeOf>() + expectTypeOf(query.isPlaceholderData).toEqualTypeOf() + + const selected = useSuspenseInfiniteQuery({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(5), + initialPageParam: 1, + getNextPageParam: () => 1, + placeholderData: (previousData) => { + expectTypeOf(previousData).toEqualTypeOf< + InfiniteData | undefined + >() + return previousData ?? { pages: [0], pageParams: [1] } + }, + select: (data) => data.pages.length, + }) + + expectTypeOf(selected.data).toEqualTypeOf() + }) + + it('should not allow enabled or throwOnError props', () => { assertType( useSuspenseInfiniteQuery({ queryKey: queryKey(), queryFn: () => Promise.resolve(5), initialPageParam: 1, getNextPageParam: () => 1, + placeholderData: { pages: [5], pageParams: [1] }, // @ts-expect-error TS2345 - placeholderData: 5, enabled: true, }), ) @@ -94,7 +123,7 @@ describe('useSuspenseInfiniteQuery', () => { expectTypeOf(data).toEqualTypeOf>() }) - it('should not return isPlaceholderData', () => { + it('should return isPlaceholderData', () => { const query = useSuspenseInfiniteQuery({ queryKey: queryKey(), queryFn: () => Promise.resolve(5), @@ -102,6 +131,6 @@ describe('useSuspenseInfiniteQuery', () => { getNextPageParam: () => 1, }) - expectTypeOf(query).not.toHaveProperty('isPlaceholderData') + expectTypeOf(query).toHaveProperty('isPlaceholderData') }) }) diff --git a/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx b/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx index 8b0fac01295..fc2b9e01769 100644 --- a/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test.tsx @@ -5,6 +5,7 @@ import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryCache, QueryClient, + keepPreviousData, skipToken, useSuspenseInfiniteQuery, } from '..' @@ -170,12 +171,45 @@ describe('useSuspenseInfiniteQuery', () => { process.env.NODE_ENV = envCopy }) - it('should still suspense if queryClient has placeholderData config', async () => { + it('should use static placeholderData without suspending', async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => 1)) + const states: Array>> = + [] + + function Page() { + const state = useSuspenseInfiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 1, + getNextPageParam: () => undefined, + placeholderData: { pages: [0], pageParams: [1] }, + }) + + states.push(state) + return
data: {state.data.pages.join(',')}
+ } + + const rendered = await renderWithSuspense(queryClient, ) + + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(rendered.getByText('data: 0')).toBeInTheDocument() + expect(states.at(-1)?.isPlaceholderData).toBe(true) + expect(queryFn).toHaveBeenCalledTimes(1) + + await act(() => vi.advanceTimersByTimeAsync(11)) + + expect(rendered.getByText('data: 1')).toBeInTheDocument() + expect(states.at(-1)?.isPlaceholderData).toBe(false) + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should use keepPreviousData from queryClient defaults', async () => { const key = queryKey() const queryClientWithPlaceholder = new QueryClient({ defaultOptions: { queries: { - placeholderData: (previousData: any) => previousData, + placeholderData: keepPreviousData, }, }, }) @@ -210,14 +244,74 @@ describe('useSuspenseInfiniteQuery', () => { ) expect(rendered.getByText('loading')).toBeInTheDocument() - await act(() => vi.advanceTimersByTimeAsync(10)) + await act(() => vi.advanceTimersByTimeAsync(11)) expect(rendered.getByText('data: 1')).toBeInTheDocument() await act(async () => { fireEvent.click(rendered.getByLabelText('toggle')) }) - expect(rendered.getByText('loading')).toBeInTheDocument() - await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(rendered.getByText('data: 1')).toBeInTheDocument() + expect(states.at(-1)).toMatchObject({ + data: { pages: [1], pageParams: [1] }, + isPlaceholderData: true, + isFetching: true, + }) + await act(() => vi.advanceTimersByTimeAsync(11)) expect(rendered.getByText('data: 2')).toBeInTheDocument() + expect(states.at(-1)).toMatchObject({ + data: { pages: [2], pageParams: [1] }, + isPlaceholderData: false, + isFetching: false, + }) + }) + + it('should start a keepPreviousData fetch during a transition', async () => { + const key = queryKey() + const queryFn = vi.fn((count: number) => sleep(10).then(() => count)) + + function Page() { + const [count, setCount] = React.useState(0) + const state = useSuspenseInfiniteQuery({ + queryKey: [...key, count], + queryFn: () => queryFn(count), + initialPageParam: 1, + getNextPageParam: () => undefined, + placeholderData: keepPreviousData, + }) + + return ( +
+ +
data: {state.data.pages.join(',')}
+
placeholder: {String(state.isPlaceholderData)}
+
+ ) + } + + const rendered = await renderWithSuspense(queryClient, ) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: 0')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(rendered.getByText('next')) + }) + + expect(rendered.getByText('data: 0')).toBeInTheDocument() + expect(rendered.getByText('placeholder: true')).toBeInTheDocument() + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(2) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data: 1')).toBeInTheDocument() + expect(rendered.getByText('placeholder: false')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(2) }) }) diff --git a/packages/react-query/src/__tests__/useSuspenseQueries.test-d.tsx b/packages/react-query/src/__tests__/useSuspenseQueries.test-d.tsx index d9230bdf940..9f8b9334f9f 100644 --- a/packages/react-query/src/__tests__/useSuspenseQueries.test-d.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQueries.test-d.tsx @@ -4,6 +4,7 @@ import { skipToken, useSuspenseQueries } from '..' import { queryOptions } from '../queryOptions' import type { OmitKeyof } from '..' import type { UseQueryOptions, UseSuspenseQueryResult } from '../types' +import type { SuspenseQueriesOptions } from '../useSuspenseQueries' describe('UseSuspenseQueries config object overload', () => { it('TData should always be defined', () => { @@ -33,6 +34,36 @@ describe('UseSuspenseQueries config object overload', () => { expectTypeOf(query2Data).toEqualTypeOf() }) + it('should allow placeholderData without previous query information', () => { + const queries: SuspenseQueriesOptions<[{ queryFnData: number }]> = [ + { + queryKey: queryKey(), + queryFn: () => Promise.resolve(5), + placeholderData: (previousData, previousQuery) => { + expectTypeOf(previousData).toEqualTypeOf() + expectTypeOf(previousQuery).toEqualTypeOf() + return 0 + }, + }, + ] + const [query] = useSuspenseQueries({ queries }) + + expectTypeOf(query.data).toEqualTypeOf() + expectTypeOf(query.isPlaceholderData).toEqualTypeOf() + + const selectedOptions = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(5), + placeholderData: 0, + select: (data) => data.toString(), + }) + const [selectedQuery] = useSuspenseQueries({ + queries: [selectedOptions], + }) + + expectTypeOf(selectedQuery.data).toEqualTypeOf() + }) + it('TData should be defined when passed through queryOptions', () => { const options = queryOptions({ queryKey: queryKey(), diff --git a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx index 86bceb52ff3..45a0d18a4cb 100644 --- a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx @@ -46,7 +46,7 @@ describe('useSuspenseQueries', () => { return
loading
} - it('should suspend on mount', () => { + it('should suspend on mount', async () => { function Page() { const queriesResults = useSuspenseQueries( { @@ -66,11 +66,13 @@ describe('useSuspenseQueries', () => { return null } - render( - }> - - , - ) + await act(async () => { + render( + }> + + , + ) + }) expect(onSuspend).toHaveBeenCalledOnce() }) @@ -733,30 +735,52 @@ describe('useSuspenseQueries', () => { expect(queryFnCount).toBe(2) }) - it('should still suspense if queryClient has placeholderData config', async () => { + it('should use queryClient placeholderData config', async () => { const key = queryKey() const queryClientWithPlaceholder = new QueryClient({ defaultOptions: { queries: { - placeholderData: (previousData: any) => previousData, + placeholderData: 'placeholder', }, }, }) + const queryFn1 = vi.fn((count: number) => + sleep(10).then(() => 'data1-' + count), + ) + const queryFn2 = vi.fn((count: number) => + sleep(10).then(() => 'data2-' + count), + ) function Page() { const [count, setCount] = React.useState(0) - const [isPending, startTransition] = React.useTransition() - const { data } = useSuspenseQuery({ - queryKey: [key, count], - queryFn: () => sleep(10).then(() => 'data' + count), + const [result1, result2] = useSuspenseQueries({ + queries: [ + { + queryKey: [...key, 1, count], + queryFn: () => queryFn1(count), + }, + { + queryKey: [...key, 2, count], + queryFn: () => queryFn2(count), + }, + ], }) return (
- -
{isPending ? 'pending' : data}
+
data1: {result1.data}
+
data2: {result2.data}
+
+ placeholder: {String(result1.isPlaceholderData)}, + {String(result2.isPlaceholderData)} +
) } @@ -766,16 +790,118 @@ describe('useSuspenseQueries', () => { , ) + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(rendered.getByText('data1: placeholder')).toBeInTheDocument() + expect(rendered.getByText('data2: placeholder')).toBeInTheDocument() + expect(rendered.getByText('placeholder: true,true')).toBeInTheDocument() + expect(queryFn1).toHaveBeenCalledTimes(1) + expect(queryFn2).toHaveBeenCalledTimes(1) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data1: data1-0')).toBeInTheDocument() + expect(rendered.getByText('data2: data2-0')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(rendered.getByText('inc')) + }) + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(rendered.getByText('data1: placeholder')).toBeInTheDocument() + expect(rendered.getByText('data2: placeholder')).toBeInTheDocument() + expect(queryFn1).toHaveBeenCalledTimes(2) + expect(queryFn2).toHaveBeenCalledTimes(2) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data1: data1-1')).toBeInTheDocument() + expect(rendered.getByText('data2: data2-1')).toBeInTheDocument() + expect(rendered.getByText('placeholder: false,false')).toBeInTheDocument() + }) + + it('should start placeholder and pending queries before suspending', async () => { + const key = queryKey() + const placeholderQueryFn = vi.fn(() => sleep(10).then(() => 'data1')) + const pendingQueryFn = vi.fn(() => sleep(10).then(() => 'data2')) + const fallback = vi.fn(() =>
loading
) + + function Page() { + const [result1, result2] = useSuspenseQueries({ + queries: [ + { + queryKey: [...key, 1], + queryFn: placeholderQueryFn, + placeholderData: 'placeholder', + }, + { queryKey: [...key, 2], queryFn: pendingQueryFn }, + ], + }) + + return ( +
+ {result1.data},{result2.data} +
+ ) + } + + const rendered = await renderWithSuspense( + queryClient, + , + React.createElement(fallback), + ) + expect(rendered.getByText('loading')).toBeInTheDocument() - await act(() => vi.advanceTimersByTimeAsync(10)) - expect(rendered.getByText('data0')).toBeInTheDocument() + expect(fallback).toHaveBeenCalledTimes(1) + expect(placeholderQueryFn).toHaveBeenCalledTimes(1) + expect(pendingQueryFn).toHaveBeenCalledTimes(1) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data1,data2')).toBeInTheDocument() + expect(fallback).toHaveBeenCalledTimes(1) + expect(placeholderQueryFn).toHaveBeenCalledTimes(1) + expect(pendingQueryFn).toHaveBeenCalledTimes(1) + }) + + it('should use placeholderData with useDeferredValue', async () => { + const key = queryKey() + const queryFn = vi.fn((count: number) => + sleep(10).then(() => 'data' + count), + ) + + function Page() { + const [count, setCount] = React.useState(0) + const deferredCount = React.useDeferredValue(count) + const [result] = useSuspenseQueries({ + queries: [ + { + queryKey: [...key, deferredCount], + queryFn: () => queryFn(deferredCount), + placeholderData: 'placeholder', + }, + ], + }) + + return ( +
+ +
data: {result.data}
+
+ ) + } + + const rendered = await renderWithSuspense(queryClient, ) + + expect(rendered.getByText('data: placeholder')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data: data0')).toBeInTheDocument() await act(async () => { fireEvent.click(rendered.getByText('inc')) }) - expect(rendered.getByText('pending')).toBeInTheDocument() - await act(() => vi.advanceTimersByTimeAsync(10)) - expect(rendered.getByText('data1')).toBeInTheDocument() + + expect(rendered.getByText('data: placeholder')).toBeInTheDocument() + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(2) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data: data1')).toBeInTheDocument() }) it('should show error boundary even with gcTime:0 (#7853)', async () => { diff --git a/packages/react-query/src/__tests__/useSuspenseQuery.test-d.tsx b/packages/react-query/src/__tests__/useSuspenseQuery.test-d.tsx index 6e0fb5534bc..cd57b7218e3 100644 --- a/packages/react-query/src/__tests__/useSuspenseQuery.test-d.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQuery.test-d.tsx @@ -1,5 +1,5 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' -import { skipToken } from '@tanstack/query-core' +import { keepPreviousData, skipToken } from '@tanstack/query-core' import { queryKey } from '@tanstack/query-test-utils' import { useSuspenseQuery } from '../useSuspenseQuery' @@ -39,13 +39,50 @@ describe('useSuspenseQuery', () => { ) }) - it('should not allow placeholderData, enabled or throwOnError props', () => { + it('should allow placeholderData', () => { + const query = useSuspenseQuery({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(5), + placeholderData: (previousData, previousQuery) => { + expectTypeOf(previousData).toEqualTypeOf() + expectTypeOf(previousQuery).not.toBeAny() + return previousData ?? 0 + }, + }) + + expectTypeOf(query.data).toEqualTypeOf() + expectTypeOf(query.isPlaceholderData).toEqualTypeOf() + + assertType( + useSuspenseQuery({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(5), + placeholderData: keepPreviousData, + }), + ) + + const selected = useSuspenseQuery({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(5), + placeholderData: 0, + select: (data) => data.toString(), + }) + + expectTypeOf(selected.data).toEqualTypeOf() + + if (query.isPlaceholderData) { + expectTypeOf(query.status).toEqualTypeOf<'success'>() + expectTypeOf(query.error).toEqualTypeOf() + } + }) + + it('should not allow enabled or throwOnError props', () => { assertType( useSuspenseQuery({ queryKey: queryKey(), queryFn: () => Promise.resolve(5), - // @ts-expect-error TS2345 placeholderData: 5, + // @ts-expect-error TS2345 enabled: true, }), ) @@ -67,13 +104,13 @@ describe('useSuspenseQuery', () => { ) }) - it('should not return isPlaceholderData', () => { + it('should return isPlaceholderData', () => { expectTypeOf( useSuspenseQuery({ queryKey: queryKey(), queryFn: () => Promise.resolve(5), }), - ).not.toHaveProperty('isPlaceholderData') + ).toHaveProperty('isPlaceholderData') }) it('should type-narrow the error field', () => { diff --git a/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx b/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx index 9645b0cc6fc..cba3408e8cb 100644 --- a/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx @@ -8,6 +8,7 @@ import { QueryClient, QueryErrorResetBoundary, experimental_streamedQuery, + keepPreviousData, skipToken, useQueryErrorResetBoundary, useSuspenseInfiniteQuery, @@ -920,16 +921,61 @@ describe('useSuspenseQuery', () => { consoleMock.mockRestore() }) - it('should still suspense if queryClient has placeholderData config', async () => { + it('should use cache-derived placeholderData without suspending', async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => ({ id: 1, text: 'full' }))) + const states: Array> = + [] + + queryClient.setQueryData([...key, 'list'], [{ id: 1, text: 'preview' }]) + + function Page() { + const state = useSuspenseQuery({ + queryKey: [...key, 'detail', 1], + queryFn, + placeholderData: () => + queryClient + .getQueryData>([...key, 'list']) + ?.find((item) => item.id === 1), + }) + + states.push(state) + return
data: {state.data.text}
+ } + + const rendered = await renderWithSuspense(queryClient, ) + + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(rendered.getByText('data: preview')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + expect(states.at(-1)).toMatchObject({ + isPlaceholderData: true, + isFetching: true, + status: 'success', + }) + + await act(() => vi.advanceTimersByTimeAsync(11)) + + expect(rendered.getByText('data: full')).toBeInTheDocument() + expect(states.at(-1)).toMatchObject({ + isPlaceholderData: false, + isFetching: false, + status: 'success', + }) + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should use keepPreviousData from queryClient defaults', async () => { const key = queryKey() const queryClientWithPlaceholder = new QueryClient({ defaultOptions: { queries: { - placeholderData: (previousData: any) => previousData, + placeholderData: keepPreviousData, }, }, }) const states: Array> = [] + const fallback = vi.fn(() =>
loading
) let count = 0 @@ -954,18 +1000,138 @@ describe('useSuspenseQuery', () => { const rendered = await renderWithSuspense( queryClientWithPlaceholder, , + React.createElement(fallback), ) expect(rendered.getByText('loading')).toBeInTheDocument() + expect(fallback).toHaveBeenCalledTimes(1) await act(() => vi.advanceTimersByTimeAsync(10)) expect(rendered.getByText('data: 1')).toBeInTheDocument() await act(async () => { fireEvent.click(rendered.getByLabelText('toggle')) }) + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(rendered.getByText('data: 1')).toBeInTheDocument() + expect(states.at(-1)).toMatchObject({ + data: 1, + isPlaceholderData: true, + isFetching: true, + }) + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data: 2')).toBeInTheDocument() + expect(states.at(-1)).toMatchObject({ + data: 2, + isPlaceholderData: false, + isFetching: false, + }) + expect(fallback).toHaveBeenCalledTimes(1) + }) + + it('should suspend when placeholderData returns undefined', async () => { + const key = queryKey() + + function Page() { + const state = useSuspenseQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + placeholderData: () => undefined, + }) + + return
data: {state.data}
+ } + + const rendered = await renderWithSuspense(queryClient, ) + expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) - expect(rendered.getByText('data: 2')).toBeInTheDocument() + expect(rendered.getByText('data: data')).toBeInTheDocument() + }) + + it('should stop suspending when placeholderData becomes defined', async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(100).then(() => 'data')) + + function Content({ showPlaceholder }: { showPlaceholder: boolean }) { + const state = useSuspenseQuery({ + queryKey: key, + queryFn, + placeholderData: showPlaceholder ? 'placeholder' : undefined, + }) + + return
data: {state.data}
+ } + + function Page() { + const [showPlaceholder, setShowPlaceholder] = React.useState(false) + + return ( +
+ + + + +
+ ) + } + + const rendered = await renderWithSuspense(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + + await act(async () => { + fireEvent.click(rendered.getByText('show placeholder')) + }) + + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(rendered.getByText('data: placeholder')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + + await act(() => vi.advanceTimersByTimeAsync(101)) + expect(rendered.getByText('data: data')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should throw a placeholder-backed fetch error to the error boundary', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + const queryFn = vi.fn( + (): Promise => + sleep(10).then(() => Promise.reject(new Error('failed'))), + ) + + function Page() { + const state = useSuspenseQuery({ + queryKey: key, + queryFn, + placeholderData: 'placeholder', + retry: false, + }) + + return
data: {state.data}
+ } + + const rendered = await renderWithSuspense( + queryClient, + error boundary}> + + , + ) + + expect(rendered.getByText('data: placeholder')).toBeInTheDocument() + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + + await act(() => vi.advanceTimersByTimeAsync(11)) + + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + consoleMock.mockRestore() }) it('should log an error when skipToken is passed as queryFn', async () => { diff --git a/packages/react-query/src/suspense.ts b/packages/react-query/src/suspense.ts index bedb583b2e8..cbad9a5f8d1 100644 --- a/packages/react-query/src/suspense.ts +++ b/packages/react-query/src/suspense.ts @@ -98,12 +98,33 @@ export const ensureSuspenseTimers = ( } } +// `undefined` skips the fetch, `false` fetches without suspending, +// and `true` fetches and suspends. export const shouldSuspend = ( defaultedOptions: | DefaultedQueryObserverOptions | undefined, result: QueryObserverResult, -) => defaultedOptions?.suspense && result.isPending + errorResetBoundary: QueryErrorResetBoundaryValue, + query: Query, +): boolean | undefined => { + if (!defaultedOptions?.suspense) { + return undefined + } + + if ( + result.isPending || + (query.state.status === 'error' && errorResetBoundary.isReset()) + ) { + return true + } + + if (result.isFetching && result.isPlaceholderData) { + return false + } + + return undefined +} export function getSuspensePromise( defaultedOptions: DefaultedQueryObserverOptions, diff --git a/packages/react-query/src/types.ts b/packages/react-query/src/types.ts index bf72c8fc723..9685040c1bf 100644 --- a/packages/react-query/src/types.ts +++ b/packages/react-query/src/types.ts @@ -8,6 +8,7 @@ import type { InfiniteData, InfiniteQueryExecuteOptions, InfiniteQueryObserverOptions, + InfiniteQueryObserverPlaceholderResult, InfiniteQueryObserverResult, MutateFunction, MutationObserverOptions, @@ -17,6 +18,7 @@ import type { QueryExecuteOptions, QueryKey, QueryObserverOptions, + QueryObserverPlaceholderResult, QueryObserverResult, SkipToken, } from '@tanstack/query-core' @@ -122,7 +124,7 @@ export interface UseSuspenseQueryOptions< TQueryKey extends QueryKey = QueryKey, > extends OmitKeyof< UseQueryOptions, - 'queryFn' | 'enabled' | 'throwOnError' | 'placeholderData' + 'queryFn' | 'enabled' | 'throwOnError' > { queryFn?: Exclude< UseQueryOptions['queryFn'], @@ -170,7 +172,7 @@ export interface UseSuspenseInfiniteQueryOptions< TPageParam = unknown, > extends OmitKeyof< UseInfiniteQueryOptions, - 'queryFn' | 'enabled' | 'throwOnError' | 'placeholderData' + 'queryFn' | 'enabled' | 'throwOnError' > { queryFn?: Exclude< UseInfiniteQueryOptions< @@ -194,13 +196,9 @@ export type UseQueryResult< TError = DefaultError, > = UseBaseQueryResult -export type UseSuspenseQueryResult< - TData = unknown, - TError = DefaultError, -> = DistributiveOmit< - DefinedQueryObserverResult, - 'isPlaceholderData' -> +export type UseSuspenseQueryResult = + | DefinedQueryObserverResult + | QueryObserverPlaceholderResult export type DefinedUseQueryResult< TData = unknown, @@ -220,10 +218,9 @@ export type DefinedUseInfiniteQueryResult< export type UseSuspenseInfiniteQueryResult< TData = unknown, TError = DefaultError, -> = OmitKeyof< - DefinedInfiniteQueryObserverResult, - 'isPlaceholderData' -> +> = + | DefinedInfiniteQueryObserverResult + | InfiniteQueryObserverPlaceholderResult export type AnyUseMutationOptions = UseMutationOptions export interface UseMutationOptions< diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index 38b17640fff..d24439e1434 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -121,15 +121,26 @@ export function useBaseQuery< // Handle suspense if (defaultedOptions.suspense) { - use( - getSuspensePromise( + const suspenseQuery = client.getQueryCache().build(client, defaultedOptions) + const suspend = shouldSuspend( + defaultedOptions, + result, + errorResetBoundary, + suspenseQuery, + ) + + if (suspend !== undefined) { + const promise = getSuspensePromise( defaultedOptions, observer, errorResetBoundary, - client.getQueryCache().build(client, defaultedOptions), - shouldSuspend(defaultedOptions, result), - ), - ) + suspenseQuery, + ) + + if (suspend) { + use(promise) + } + } } // Handle error boundary diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index b13d0fcddc6..c99e1cfd07d 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -296,14 +296,22 @@ export function useQueries< return undefined } + const query = client.getQueryCache().build(client, opts) + const suspend = shouldSuspend(opts, result, errorResetBoundary, query) + + if (suspend === undefined) { + return undefined + } + const queryObserver = new QueryObserver(client, opts) - return getSuspensePromise( + const promise = getSuspensePromise( opts, queryObserver, errorResetBoundary, - client.getQueryCache().build(client, opts), - shouldSuspend(opts, result), + query, ) + + return suspend ? promise : undefined }) // Start every fetch before calling use(), because use() suspends immediately. diff --git a/packages/react-query/src/useSuspenseInfiniteQuery.ts b/packages/react-query/src/useSuspenseInfiniteQuery.ts index 20b7fc71117..5163988508f 100644 --- a/packages/react-query/src/useSuspenseInfiniteQuery.ts +++ b/packages/react-query/src/useSuspenseInfiniteQuery.ts @@ -5,7 +5,6 @@ import { defaultThrowOnError } from './suspense' import type { DefaultError, InfiniteData, - InfiniteQueryObserverSuccessResult, QueryClient, QueryKey, QueryObserver, @@ -43,9 +42,8 @@ export function useSuspenseInfiniteQuery< enabled: true, suspense: true, throwOnError: defaultThrowOnError, - placeholderData: undefined, }, InfiniteQueryObserver as typeof QueryObserver, queryClient, - ) as InfiniteQueryObserverSuccessResult + ) as UseSuspenseInfiniteQueryResult } diff --git a/packages/react-query/src/useSuspenseQueries.ts b/packages/react-query/src/useSuspenseQueries.ts index aa10abfb3d1..8d5ec7ab288 100644 --- a/packages/react-query/src/useSuspenseQueries.ts +++ b/packages/react-query/src/useSuspenseQueries.ts @@ -5,11 +5,27 @@ import { defaultThrowOnError } from './suspense' import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types' import type { DefaultError, + OmitKeyof, + QueriesPlaceholderDataFunction, QueryClient, QueryFunction, + QueryKey, ThrowOnError, } from '@tanstack/query-core' +// `placeholderData` functions in useQueries never receive previous query data. +type UseSuspenseQueryOptionsForUseSuspenseQueries< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = OmitKeyof< + UseSuspenseQueryOptions, + 'placeholderData' +> & { + placeholderData?: TQueryFnData | QueriesPlaceholderDataFunction +} + // Avoid TS depth-limit error in case of large array literal type MAXIMUM_DEPTH = 20 @@ -23,18 +39,22 @@ type GetUseSuspenseQueryOptions = error?: infer TError data: infer TData } - ? UseSuspenseQueryOptions + ? UseSuspenseQueryOptionsForUseSuspenseQueries : T extends { queryFnData: infer TQueryFnData; error?: infer TError } - ? UseSuspenseQueryOptions + ? UseSuspenseQueryOptionsForUseSuspenseQueries : T extends { data: infer TData; error?: infer TError } - ? UseSuspenseQueryOptions + ? UseSuspenseQueryOptionsForUseSuspenseQueries : // Part 2: responsible for applying explicit type parameter to function arguments, if tuple [TQueryFnData, TError, TData] T extends [infer TQueryFnData, infer TError, infer TData] - ? UseSuspenseQueryOptions + ? UseSuspenseQueryOptionsForUseSuspenseQueries< + TQueryFnData, + TError, + TData + > : T extends [infer TQueryFnData, infer TError] - ? UseSuspenseQueryOptions + ? UseSuspenseQueryOptionsForUseSuspenseQueries : T extends [infer TQueryFnData] - ? UseSuspenseQueryOptions + ? UseSuspenseQueryOptionsForUseSuspenseQueries : // Part 3: responsible for inferring and enforcing type if no explicit parameter was provided T extends { queryFn?: @@ -43,7 +63,7 @@ type GetUseSuspenseQueryOptions = select?: (data: any) => infer TData throwOnError?: ThrowOnError } - ? UseSuspenseQueryOptions< + ? UseSuspenseQueryOptionsForUseSuspenseQueries< TQueryFnData, TError, TData, @@ -55,14 +75,14 @@ type GetUseSuspenseQueryOptions = | SkipTokenForUseQueries throwOnError?: ThrowOnError } - ? UseSuspenseQueryOptions< + ? UseSuspenseQueryOptionsForUseSuspenseQueries< TQueryFnData, TError, TQueryFnData, TQueryKey > : // Fallback - UseSuspenseQueryOptions + UseSuspenseQueryOptionsForUseSuspenseQueries type GetUseSuspenseQueryResult = // Part 1: responsible for mapping explicit type parameter to function result, if object @@ -112,7 +132,7 @@ export type SuspenseQueriesOptions< TResults extends Array = [], TDepth extends ReadonlyArray = [], > = TDepth['length'] extends MAXIMUM_DEPTH - ? Array + ? Array : T extends [] ? [] : T extends [infer Head] @@ -128,7 +148,7 @@ export type SuspenseQueriesOptions< : // If T is *some* array but we couldn't assign unknown[] to it, then it must hold some known/homogeneous type! // use this to infer the param types in the case of Array.map() argument T extends Array< - UseSuspenseQueryOptions< + UseSuspenseQueryOptionsForUseSuspenseQueries< infer TQueryFnData, infer TError, infer TData, @@ -136,10 +156,15 @@ export type SuspenseQueriesOptions< > > ? Array< - UseSuspenseQueryOptions + UseSuspenseQueryOptionsForUseSuspenseQueries< + TQueryFnData, + TError, + TData, + TQueryKey + > > : // Fallback - Array + Array /** * SuspenseQueriesResults reducer recursively maps type param to results @@ -202,7 +227,6 @@ export function useSuspenseQueries(options: any, queryClient?: QueryClient) { suspense: true, throwOnError: defaultThrowOnError, enabled: true, - placeholderData: undefined, } }), }, diff --git a/packages/react-query/src/useSuspenseQuery.ts b/packages/react-query/src/useSuspenseQuery.ts index 7dfdb064778..cdfcd7cb60c 100644 --- a/packages/react-query/src/useSuspenseQuery.ts +++ b/packages/react-query/src/useSuspenseQuery.ts @@ -26,7 +26,6 @@ export function useSuspenseQuery< enabled: true, suspense: true, throwOnError: defaultThrowOnError, - placeholderData: undefined, }, QueryObserver, queryClient,