diff --git a/.changeset/tasty-ears-study.md b/.changeset/tasty-ears-study.md new file mode 100644 index 0000000000..187cd583c0 --- /dev/null +++ b/.changeset/tasty-ears-study.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-query': patch +--- + +react(suspense): switch from throwing promises to use() diff --git a/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx b/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx index 21d594129f..d923cc1bb4 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, renderWithSuspense } from './utils' describe('QueryErrorResetBoundary', () => { let queryCache: QueryCache @@ -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() @@ -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() @@ -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() @@ -593,7 +599,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient, {({ reset }) => ( @@ -612,9 +618,7 @@ describe('QueryErrorResetBoundary', () => { )} > - loading}> - - + )} , @@ -625,13 +629,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() @@ -722,7 +730,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 +912,7 @@ describe('QueryErrorResetBoundary', () => { return
{data}
} - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient, {({ reset }) => ( @@ -921,9 +931,7 @@ describe('QueryErrorResetBoundary', () => { )} > - - - + )} , @@ -936,7 +944,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 edae5e6031..4541763b94 100644 --- a/packages/react-query/src/__tests__/suspense.test.tsx +++ b/packages/react-query/src/__tests__/suspense.test.tsx @@ -1,19 +1,14 @@ 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 { renderWithSuspense } from './utils' import type { StaleTime } from '@tanstack/query-core' import type { QueryKey } from '..' -function renderWithSuspense(client: QueryClient, ui: React.ReactNode) { - return render( - - {ui} - , - ) -} - function createTestQuery(options: { fetchCount: { count: number } queryKey: QueryKey @@ -54,6 +49,105 @@ 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, + observer.getCurrentQuery(), + ) + const secondPromise = getSuspensePromise( + options, + observer, + errorResetBoundary, + observer.getCurrentQuery(), + ) + + expect(secondPromise).toBe(firstPromise) + + await vi.advanceTimersByTimeAsync(10) + 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) => { + 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 +155,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 +181,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 +207,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 +233,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 +259,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 +285,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 +311,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 7e3df99f01..ec4b9aba6a 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 { renderWithSuspense } from './utils' describe('usePrefetchInfiniteQuery', () => { let queryCache: QueryCache @@ -68,15 +68,19 @@ describe('usePrefetchInfiniteQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(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 renderWithSuspense(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 renderWithSuspense(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 9b597d4d28..522726f647 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, renderWithSuspense } from './utils' describe('usePrefetchQuery', () => { let queryCache: QueryCache @@ -53,7 +53,7 @@ describe('usePrefetchQuery', () => { ) } - const rendered = renderWithClient(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 = renderWithClient(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 = renderWithClient(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 = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(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 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 f2116377fc..8b0fac0129 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 { renderWithSuspense } from './utils' import type { InfiniteData, UseSuspenseInfiniteQueryResult } from '..' describe('useSuspenseInfiniteQuery', () => { @@ -51,12 +51,7 @@ describe('useSuspenseInfiniteQuery', () => { ) } - const rendered = renderWithClient( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -68,7 +63,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 +77,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 +104,7 @@ describe('useSuspenseInfiniteQuery', () => { ) } - renderWithClient(queryClient, ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseInfiniteQuery', @@ -115,7 +112,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,12 +132,7 @@ describe('useSuspenseInfiniteQuery', () => { return null } - renderWithClient( - queryClient, - - - , - ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseInfiniteQuery', @@ -150,7 +142,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,12 +162,7 @@ describe('useSuspenseInfiniteQuery', () => { return null } - renderWithClient( - queryClient, - - - , - ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).not.toHaveBeenCalled() @@ -217,18 +204,18 @@ describe('useSuspenseInfiniteQuery', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClientWithPlaceholder, - - - , + , ) expect(rendered.getByText('loading')).toBeInTheDocument() 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 eef3bdcc13..86bceb52ff 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 { renderWithSuspense } from './utils' import type { UseSuspenseQueryOptions } from '..' describe('useSuspenseQueries', () => { @@ -88,23 +88,47 @@ describe('useSuspenseQueries', () => { queryClient, ) - React.useEffect(() => { - onQueriesResolution(queriesResults) - }, [queriesResults]) - - return null + return
data: {queriesResults.join(',')}
} - render( - }> - - , + const rendered = await renderWithSuspense( + 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 renderWithSuspense(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 +185,29 @@ describe('useSuspenseQueries', () => { queryClient, ) - React.useEffect(() => { - onQueriesResolution(queriesResults) - }, [queriesResults]) - - return null + return
data: {queriesResults.join(',')}
} - const { rerender } = render( - }> - - , + const rendered = await renderWithSuspense( + 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,32 +233,32 @@ describe('useSuspenseQueries', () => { queryClient, ) - React.useEffect(() => { - onQueriesResolution(queriesResults) - }, [queriesResults]) - - return null + return
data: {queriesResults.join(',')}
} - const { rerender } = render( - }> - - , + const rendered = await renderWithSuspense( + queryClient, + , + , ) 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,12 +280,7 @@ describe('useSuspenseQueries', () => { return

{data}

} - const rendered = renderWithClient( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -312,12 +330,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient,
error boundary
}> - - - +
, ) @@ -328,9 +344,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,12 +404,7 @@ describe('useSuspenseQueries', () => { ) } - renderWithClient( - queryClient, - }> - - , - ) + await renderWithSuspense(queryClient, , ) await act(() => vi.advanceTimersByTimeAsync(localDuration)) @@ -452,11 +463,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient, - }> - - , + , + , ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -510,11 +520,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient, - }> - - , + , + , ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -550,11 +559,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient, - loading}> - - , + , +
loading
, ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -564,13 +572,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,12 +615,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient,
error boundary
}> - - - +
, ) @@ -616,7 +626,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,18 +662,15 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() 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 +714,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient, , @@ -715,7 +724,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,18 +761,18 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClientWithPlaceholder, - - - , + , ) expect(rendered.getByText('loading')).toBeInTheDocument() 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 +814,7 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -859,11 +870,13 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(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 +887,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 +916,7 @@ describe('useSuspenseQueries', () => { ) } - renderWithClient(queryClient, ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseQueries', @@ -911,7 +924,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,12 +946,7 @@ describe('useSuspenseQueries', () => { return null } - renderWithClient( - queryClient, - - - , - ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseQueries', @@ -947,7 +955,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,12 +977,7 @@ describe('useSuspenseQueries', () => { return null } - renderWithClient( - queryClient, - - - , - ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).not.toHaveBeenCalled() consoleErrorSpy.mockRestore() @@ -1009,11 +1012,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient, - loading}> - - , + , +
loading
, ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -1062,11 +1064,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient, - loading}> - - , + , +
loading
, ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -1087,7 +1088,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,11 +1120,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient, - loading}> - - , + , +
loading
, ) // No suspend, fresh cached data shown immediately @@ -1172,11 +1172,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient, - loading}> - - , + , +
loading
, ) // No suspend, cached data shown immediately @@ -1240,11 +1239,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient, - loading}> - - , + , +
loading
, ) // No suspend, cached data shown immediately @@ -1303,11 +1301,10 @@ describe('useSuspenseQueries', () => { ) } - const rendered = renderWithClient( + 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 3c33abd925..9645b0cc6f 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 { renderWithSuspense } from './utils' import type { InfiniteData, UseSuspenseInfiniteQueryResult, @@ -62,18 +62,13 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() 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 +79,104 @@ 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 renderWithSuspense(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 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>> = @@ -109,12 +202,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -126,7 +214,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,12 +239,7 @@ describe('useSuspenseQuery', () => { return <>rendered } - const rendered = renderWithClient( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -189,20 +274,24 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(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 +321,7 @@ describe('useSuspenseQuery', () => { return
rendered
} - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient, {({ reset }) => ( @@ -252,9 +341,7 @@ describe('useSuspenseQuery', () => { )} > - - - + )} , @@ -265,7 +352,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 +387,7 @@ describe('useSuspenseQuery', () => { return
rendered
} - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClient, {({ reset }) => ( @@ -317,9 +406,7 @@ describe('useSuspenseQuery', () => { )} > - - - + )} , @@ -330,7 +417,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 +427,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 +462,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -414,13 +505,15 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(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 +565,16 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(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 +582,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 +624,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -569,7 +666,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -622,7 +719,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) // render suspense fallback (loading) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -634,7 +731,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 +793,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) // render suspense fallback (loading) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -706,7 +805,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,12 +843,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -802,7 +898,7 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(queryClient, ) // render suspense fallback (loading) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -814,7 +910,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,24 +951,24 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient( + const rendered = await renderWithSuspense( queryClientWithPlaceholder, - - - , + , ) expect(rendered.getByText('loading')).toBeInTheDocument() 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 +993,7 @@ describe('useSuspenseQuery', () => { ) } - renderWithClient(queryClient, ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseQuery', @@ -917,12 +1015,7 @@ describe('useSuspenseQuery', () => { return
count: {state.data}
} - const rendered = renderWithClient( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() await act(() => vi.advanceTimersByTimeAsync(10)) @@ -935,7 +1028,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,12 +1046,7 @@ describe('useSuspenseQuery', () => { return null } - renderWithClient( - queryClient, - - - , - ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).toHaveBeenCalledWith( 'skipToken is not allowed for useSuspenseQuery', @@ -968,7 +1056,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,12 +1074,7 @@ describe('useSuspenseQuery', () => { return null } - renderWithClient( - queryClient, - - - , - ) + await renderWithSuspense(queryClient, ) expect(consoleErrorSpy).not.toHaveBeenCalled() @@ -1023,11 +1106,13 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(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,12 +1142,7 @@ describe('useSuspenseQuery', () => { queryCache, }) - const rendered = renderWithClient( - streamedClient, - - - , - ) + const rendered = await renderWithSuspense(streamedClient, ) expect(rendered.getByText('loading')).toBeInTheDocument() @@ -1083,12 +1163,7 @@ describe('useSuspenseQuery', () => { return
data: {data}
} - const rendered = renderWithClient( - queryClient, - - - , - ) + const rendered = await renderWithSuspense(queryClient, ) await act(() => vi.advanceTimersByTimeAsync(0)) @@ -1121,11 +1196,13 @@ describe('useSuspenseQuery', () => { ) } - const rendered = renderWithClient(queryClient, ) + const rendered = await renderWithSuspense(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 ae8e2fb23f..2146e4fd2e 100644 --- a/packages/react-query/src/__tests__/utils.tsx +++ b/packages/react-query/src/__tests__/utils.tsx @@ -22,6 +22,23 @@ export function renderWithClient( } as any } +export async function renderWithSuspense( + client: QueryClient, + ui: React.ReactNode, + fallback: React.ReactNode = 'loading', +): 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 b49e242ef5..bedb583b2e 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,57 @@ 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 + settled: boolean +} + +const suspensePromiseCache = new WeakMap< + Query, + SuspensePromiseEntry +>() + export const defaultThrowOnError = < TQueryFnData = unknown, TError = DefaultError, @@ -53,23 +105,44 @@ 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() - }) + query: Query, + shouldFetch = true, +): Promise { + const shouldFetchNow = + shouldFetch || + (query.state.status === 'error' && errorResetBoundary.isReset()) + + const cached = suspensePromiseCache.get(query) + + 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 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) + + fetchPromise.catch(() => errorResetBoundary.clearReset()) + + return entry.promise +} diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index 30beffc14b..38b17640ff 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -12,8 +12,9 @@ import { import { useIsRestoring } from './IsRestoringProvider' import { ensureSuspenseTimers, - fetchOptimistic, + getSuspensePromise, shouldSuspend, + use, } from './suspense' import type { QueryClient, @@ -119,8 +120,16 @@ export function useBaseQuery< }, [defaultedOptions, observer]) // Handle suspense - if (shouldSuspend(defaultedOptions, result)) { - throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary) + if (defaultedOptions.suspense) { + use( + getSuspensePromise( + defaultedOptions, + observer, + errorResetBoundary, + client.getQueryCache().build(client, defaultedOptions), + shouldSuspend(defaultedOptions, result), + ), + ) } // Handle error boundary diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index 437718a988..b13d0fcddc 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -17,8 +17,9 @@ import { } from './errorBoundaryUtils' import { ensureSuspenseTimers, - fetchOptimistic, + getSuspensePromise, shouldSuspend, + use, } from './suspense' import type { DefinedUseQueryResult, @@ -288,25 +289,29 @@ 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 [] - }) - : [] + const queryObserver = new QueryObserver(client, opts) + return getSuspensePromise( + opts, + queryObserver, + errorResetBoundary, + client.getQueryCache().build(client, opts), + shouldSuspend(opts, result), + ) + }) - if (suspensePromises.length > 0) { - throw Promise.all(suspensePromises) - } + // 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]