From 5d69ad773a7a2df632df48eefce6fd9772d26db7 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 12 Oct 2024 11:36:30 +0200 Subject: [PATCH] fix(query-core): don't finalizeThenable of different queries (#8171) * fix(query-core): don't finalizeThenable of different queries if we have multiple promises in-flight, once they resolve, we need to make sure to not finalize with data / error from a different key; otherwise, we might see intermediate data of non-matching keys * chore: extract promise tests to their own file * test: add a test case for not resolving with intermediate data * fix: only stop finalizing if we were previously in pending state this makes sure we can read stale data from entries when switching to them immediately * chore: add another test for background updates --- packages/query-core/src/queryObserver.ts | 5 +- .../src/__tests__/useQuery.promise.test.tsx | 967 ++++++++++++++++++ .../src/__tests__/useQuery.test.tsx | 841 +-------------- 3 files changed, 972 insertions(+), 841 deletions(-) create mode 100644 packages/react-query/src/__tests__/useQuery.promise.test.tsx diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index eecf792b41..3dc751f5cd 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -621,7 +621,10 @@ export class QueryObserver< switch (prevThenable.status) { case 'pending': // Finalize the previous thenable if it was pending - finalizeThenableIfPossible(prevThenable) + // and we are still observing the same query + if (query.queryHash === prevQuery.queryHash) { + finalizeThenableIfPossible(prevThenable) + } break case 'fulfilled': if ( diff --git a/packages/react-query/src/__tests__/useQuery.promise.test.tsx b/packages/react-query/src/__tests__/useQuery.promise.test.tsx new file mode 100644 index 0000000000..7f3e88673b --- /dev/null +++ b/packages/react-query/src/__tests__/useQuery.promise.test.tsx @@ -0,0 +1,967 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { fireEvent, waitFor } from '@testing-library/react' +import * as React from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { keepPreviousData, useQuery } from '..' +import { QueryCache } from '../index' +import { createQueryClient, queryKey, renderWithClient, sleep } from './utils' + +describe('useQuery().promise', () => { + const queryCache = new QueryCache() + const queryClient = createQueryClient({ + queryCache, + }) + + beforeAll(() => { + queryClient.setDefaultOptions({ + queries: { experimental_prefetchInRender: true }, + }) + }) + afterAll(() => { + queryClient.setDefaultOptions({ + queries: { experimental_prefetchInRender: false }, + }) + }) + it('should work with a basic test', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + suspenseRenderCount++ + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return 'test' + }, + }) + + pageRenderCount++ + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test')) + + // Suspense should rendered once since `.promise` is the only watched property + expect(suspenseRenderCount).toBe(1) + + // Page should be rendered once since since the promise do not change + expect(pageRenderCount).toBe(1) + }) + + it('colocate suspense and promise', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + let callCount = 0 + + function MyComponent() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + callCount++ + await sleep(1) + return 'test' + }, + staleTime: 1000, + }) + const data = React.use(query.promise) + + return <>{data} + } + + function Loading() { + suspenseRenderCount++ + return <>loading.. + } + function Page() { + pageRenderCount++ + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test')) + + // Suspense should rendered once since `.promise` is the only watched property + expect(suspenseRenderCount).toBe(1) + + // Page should be rendered once since since the promise do not change + expect(pageRenderCount).toBe(1) + + expect(callCount).toBe(1) + }) + + it('parallel queries', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + let callCount = 0 + + function MyComponent() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + callCount++ + await sleep(1) + return 'test' + }, + staleTime: 1000, + }) + const data = React.use(query.promise) + + return data + } + + function Loading() { + suspenseRenderCount++ + return <>loading.. + } + function Page() { + pageRenderCount++ + return ( + <> + }> + + + + + + + + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => { + expect(rendered.queryByText('loading..')).not.toBeInTheDocument() + }) + + expect(rendered.container.textContent).toBe('test'.repeat(5)) + + // Suspense should rendered once since `.promise` is the only watched property + expect(suspenseRenderCount).toBe(1) + + // Page should be rendered once since since the promise do not change + expect(pageRenderCount).toBe(1) + + expect(callCount).toBe(1) + }) + + it('should work with initial data', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + suspenseRenderCount++ + + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return 'test' + }, + initialData: 'initial', + }) + pageRenderCount++ + + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('initial')) + await waitFor(() => rendered.getByText('test')) + + // Suspense boundary should never be rendered since it has data immediately + expect(suspenseRenderCount).toBe(0) + // Page should only be rendered twice since, the promise will get swapped out when new result comes in + expect(pageRenderCount).toBe(2) + }) + + it('should not fetch with initial data and staleTime', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(1) + return 'test' + }) + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + suspenseRenderCount++ + + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn, + initialData: 'initial', + staleTime: 1000, + }) + + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('initial')) + + // Suspense boundary should never be rendered since it has data immediately + expect(suspenseRenderCount).toBe(0) + // should not call queryFn because of staleTime + initialData combo + expect(queryFn).toHaveBeenCalledTimes(0) + }) + + it('should work with static placeholderData', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + suspenseRenderCount++ + + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return 'test' + }, + placeholderData: 'placeholder', + }) + pageRenderCount++ + + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('placeholder')) + await waitFor(() => rendered.getByText('test')) + + // Suspense boundary should never be rendered since it has data immediately + expect(suspenseRenderCount).toBe(0) + // Page should only be rendered twice since, the promise will get swapped out when new result comes in + expect(pageRenderCount).toBe(2) + }) + + it('should work with placeholderData: keepPreviousData', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + suspenseRenderCount++ + + return <>loading.. + } + function Page() { + const [count, setCount] = React.useState(0) + const query = useQuery({ + queryKey: [...key, count], + queryFn: async () => { + await sleep(1) + return 'test-' + count + }, + placeholderData: keepPreviousData, + }) + + return ( +
+ }> + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test-0')) + + // Suspense boundary should only be rendered initially + expect(suspenseRenderCount).toBe(1) + + fireEvent.click(rendered.getByRole('button', { name: 'increment' })) + + await waitFor(() => rendered.getByText('test-1')) + + // no more suspense boundary rendering + expect(suspenseRenderCount).toBe(1) + }) + + it('should be possible to select a part of the data with select', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + return <>{data} + } + + function Loading() { + suspenseRenderCount++ + return <>loading.. + } + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return { name: 'test' } + }, + select: (data) => data.name, + }) + + pageRenderCount++ + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => { + rendered.getByText('test') + }) + expect(suspenseRenderCount).toBe(1) + expect(pageRenderCount).toBe(1) + }) + + it('should throw error if the promise fails', async () => { + let suspenseRenderCount = 0 + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const key = queryKey() + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + suspenseRenderCount++ + return <>loading.. + } + + let queryCount = 0 + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + if (++queryCount > 1) { + // second time this query mounts, it should not throw + return 'data' + } + throw new Error('Error test') + }, + retry: false, + }) + + return ( + }> + + + ) + } + + const rendered = renderWithClient( + queryClient, + ( + <> + error boundary{' '} + + + )} + > + + , + ) + + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('error boundary')) + + consoleMock.mockRestore() + + fireEvent.click(rendered.getByText('resetErrorBoundary')) + + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('data')) + + expect(queryCount).toBe(2) + }) + + it('should recreate promise with data changes', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + suspenseRenderCount++ + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return 'test1' + }, + }) + + pageRenderCount++ + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test1')) + + // Suspense should rendered once since `.promise` is the only watched property + expect(pageRenderCount).toBe(1) + + queryClient.setQueryData(key, 'test2') + + await waitFor(() => rendered.getByText('test2')) + + // Suspense should rendered once since `.promise` is the only watched property + expect(suspenseRenderCount).toBe(1) + + // Page should be rendered once since since the promise changed once + expect(pageRenderCount).toBe(2) + }) + + it('should dedupe when re-fetched with queryClient.fetchQuery while suspending', async () => { + const key = queryKey() + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(10) + return 'test' + }) + + const options = { + queryKey: key, + queryFn, + } + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const query = useQuery(options) + + return ( +
+ }> + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + fireEvent.click(rendered.getByText('fetch')) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test')) + + expect(queryFn).toHaveBeenCalledOnce() + }) + + it('should dedupe when re-fetched with refetchQueries while suspending', async () => { + const key = queryKey() + let count = 0 + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(10) + return 'test' + count++ + }) + + const options = { + queryKey: key, + queryFn, + } + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const query = useQuery(options) + + return ( +
+ }> + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + fireEvent.click(rendered.getByText('refetch')) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test0')) + + expect(queryFn).toHaveBeenCalledOnce() + }) + + it('should stay pending when canceled with cancelQueries while suspending until refetched', async () => { + const key = queryKey() + let count = 0 + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(10) + return 'test' + count++ + }) + + const options = { + queryKey: key, + queryFn, + } + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const query = useQuery(options) + + return ( +
+ }> + + + + +
+ ) + } + + const rendered = renderWithClient( + queryClient, + <>error boundary}> + + , + ) + fireEvent.click(rendered.getByText('cancel')) + await waitFor(() => rendered.getByText('loading..')) + // await waitFor(() => rendered.getByText('error boundary')) + await waitFor(() => + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'pending', + fetchStatus: 'idle', + }), + ) + + expect(queryFn).toHaveBeenCalledOnce() + + fireEvent.click(rendered.getByText('fetch')) + + await waitFor(() => rendered.getByText('hello')) + }) + + it('should resolve to previous data when canceled with cancelQueries while suspending', async () => { + const key = queryKey() + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(10) + return 'test' + }) + + const options = { + queryKey: key, + queryFn, + } + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const query = useQuery(options) + + return ( +
+ }> + + + +
+ ) + } + + queryClient.setQueryData(key, 'initial') + + const rendered = renderWithClient(queryClient, ) + fireEvent.click(rendered.getByText('cancel')) + await waitFor(() => rendered.getByText('initial')) + + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should suspend when not enabled', async () => { + const key = queryKey() + + const options = (count: number) => ({ + queryKey: [...key, count], + queryFn: async () => { + await sleep(10) + return 'test' + count + }, + }) + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const [count, setCount] = React.useState(0) + const query = useQuery({ ...options(count), enabled: count > 0 }) + + return ( +
+ }> + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + fireEvent.click(rendered.getByText('enable')) + await waitFor(() => rendered.getByText('test1')) + }) + + it('should show correct data when read from cache only (staleTime)', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + queryClient.setQueryData(key, 'initial') + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + suspenseRenderCount++ + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return 'test' + }, + staleTime: Infinity, + }) + + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('initial')) + + expect(suspenseRenderCount).toBe(0) + }) + + it('should show correct data when switching between cache entries without re-fetches', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + suspenseRenderCount++ + return <>loading.. + } + function Page() { + const [count, setCount] = React.useState(0) + const query = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(10) + return 'test' + count + }, + staleTime: Infinity, + }) + + return ( +
+ }> + + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test0')) + + expect(suspenseRenderCount).toBe(1) + + fireEvent.click(rendered.getByText('inc')) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test1')) + + expect(suspenseRenderCount).toBe(2) + + fireEvent.click(rendered.getByText('dec')) + await waitFor(() => rendered.getByText('test0')) + + // no more suspending when going back to test0 + expect(suspenseRenderCount).toBe(2) + }) + + it('should not resolve with intermediate data when keys are switched', async () => { + const key = queryKey() + const renderedData: Array = [] + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + renderedData.push(data) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const [count, setCount] = React.useState(0) + const query = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(10) + return 'test' + count + }, + staleTime: Infinity, + }) + + return ( +
+ }> + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test0')) + + fireEvent.click(rendered.getByText('inc')) + fireEvent.click(rendered.getByText('inc')) + fireEvent.click(rendered.getByText('inc')) + + await waitFor(() => rendered.getByText('loading..')) + + await waitFor(() => rendered.getByText('test3')) + + expect(renderedData).toEqual(['test0', 'test3']) + }) + + it('should not resolve with intermediate data when keys are switched (with background updates)', async () => { + const key = queryKey() + const renderedData: Array = [] + let modifier = '' + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + renderedData.push(data) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const [count, setCount] = React.useState(0) + const query = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(10) + return 'test' + count + modifier + }, + }) + + return ( +
+ }> + + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test0')) + + fireEvent.click(rendered.getByText('inc')) + await sleep(1) + fireEvent.click(rendered.getByText('inc')) + await sleep(7) + fireEvent.click(rendered.getByText('inc')) + await sleep(5) + + await waitFor(() => rendered.getByText('loading..')) + + await waitFor(() => rendered.getByText('test3')) + + modifier = 'new' + + fireEvent.click(rendered.getByText('dec')) + fireEvent.click(rendered.getByText('dec')) + fireEvent.click(rendered.getByText('dec')) + + await waitFor(() => rendered.getByText('test0new')) + + expect(renderedData).toEqual([ + 'test0', // fresh data + 'test3', // fresh data + 'test2', // stale data + 'test1', // stale data + 'test0', // stale data + 'test0new', // fresh data, background refetch, only for latest + ]) + }) +}) diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index 6a60386872..e336b99815 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -1,13 +1,4 @@ -import { - afterAll, - beforeAll, - describe, - expect, - expectTypeOf, - it, - test, - vi, -} from 'vitest' +import { describe, expect, expectTypeOf, it, test, vi } from 'vitest' import { act, fireEvent, render, waitFor } from '@testing-library/react' import * as React from 'react' import { ErrorBoundary } from 'react-error-boundary' @@ -6643,834 +6634,4 @@ describe('useQuery', () => { consoleMock.mockRestore() }) - - describe('useQuery().promise', () => { - beforeAll(() => { - queryClient.setDefaultOptions({ - queries: { experimental_prefetchInRender: true }, - }) - }) - afterAll(() => { - queryClient.setDefaultOptions({ - queries: { experimental_prefetchInRender: false }, - }) - }) - it('should work with a basic test', async () => { - const key = queryKey() - let suspenseRenderCount = 0 - let pageRenderCount = 0 - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - - function Loading() { - suspenseRenderCount++ - return <>loading.. - } - function Page() { - const query = useQuery({ - queryKey: key, - queryFn: async () => { - await sleep(1) - return 'test' - }, - }) - - pageRenderCount++ - return ( - }> - - - ) - } - - const rendered = renderWithClient(queryClient, ) - await waitFor(() => rendered.getByText('loading..')) - await waitFor(() => rendered.getByText('test')) - - // Suspense should rendered once since `.promise` is the only watched property - expect(suspenseRenderCount).toBe(1) - - // Page should be rendered once since since the promise do not change - expect(pageRenderCount).toBe(1) - }) - - it('colocate suspense and promise', async () => { - const key = queryKey() - let suspenseRenderCount = 0 - let pageRenderCount = 0 - let callCount = 0 - - function MyComponent() { - const query = useQuery({ - queryKey: key, - queryFn: async () => { - callCount++ - await sleep(1) - return 'test' - }, - staleTime: 1000, - }) - const data = React.use(query.promise) - - return <>{data} - } - - function Loading() { - suspenseRenderCount++ - return <>loading.. - } - function Page() { - pageRenderCount++ - return ( - }> - - - ) - } - - const rendered = renderWithClient(queryClient, ) - await waitFor(() => rendered.getByText('loading..')) - await waitFor(() => rendered.getByText('test')) - - // Suspense should rendered once since `.promise` is the only watched property - expect(suspenseRenderCount).toBe(1) - - // Page should be rendered once since since the promise do not change - expect(pageRenderCount).toBe(1) - - expect(callCount).toBe(1) - }) - - it('parallel queries', async () => { - const key = queryKey() - let suspenseRenderCount = 0 - let pageRenderCount = 0 - let callCount = 0 - - function MyComponent() { - const query = useQuery({ - queryKey: key, - queryFn: async () => { - callCount++ - await sleep(1) - return 'test' - }, - staleTime: 1000, - }) - const data = React.use(query.promise) - - return data - } - - function Loading() { - suspenseRenderCount++ - return <>loading.. - } - function Page() { - pageRenderCount++ - return ( - <> - }> - - - - - - - - - - ) - } - - const rendered = renderWithClient(queryClient, ) - await waitFor(() => rendered.getByText('loading..')) - await waitFor(() => { - expect(rendered.queryByText('loading..')).not.toBeInTheDocument() - }) - - expect(rendered.container.textContent).toBe('test'.repeat(5)) - - // Suspense should rendered once since `.promise` is the only watched property - expect(suspenseRenderCount).toBe(1) - - // Page should be rendered once since since the promise do not change - expect(pageRenderCount).toBe(1) - - expect(callCount).toBe(1) - }) - - it('should work with initial data', async () => { - const key = queryKey() - let suspenseRenderCount = 0 - let pageRenderCount = 0 - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - function Loading() { - suspenseRenderCount++ - - return <>loading.. - } - function Page() { - const query = useQuery({ - queryKey: key, - queryFn: async () => { - await sleep(1) - return 'test' - }, - initialData: 'initial', - }) - pageRenderCount++ - - return ( - }> - - - ) - } - - const rendered = renderWithClient(queryClient, ) - await waitFor(() => rendered.getByText('initial')) - await waitFor(() => rendered.getByText('test')) - - // Suspense boundary should never be rendered since it has data immediately - expect(suspenseRenderCount).toBe(0) - // Page should only be rendered twice since, the promise will get swapped out when new result comes in - expect(pageRenderCount).toBe(2) - }) - - it('should not fetch with initial data and staleTime', async () => { - const key = queryKey() - let suspenseRenderCount = 0 - const queryFn = vi.fn().mockImplementation(async () => { - await sleep(1) - return 'test' - }) - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - function Loading() { - suspenseRenderCount++ - - return <>loading.. - } - function Page() { - const query = useQuery({ - queryKey: key, - queryFn, - initialData: 'initial', - staleTime: 1000, - }) - - return ( - }> - - - ) - } - - const rendered = renderWithClient(queryClient, ) - await waitFor(() => rendered.getByText('initial')) - - // Suspense boundary should never be rendered since it has data immediately - expect(suspenseRenderCount).toBe(0) - // should not call queryFn because of staleTime + initialData combo - expect(queryFn).toHaveBeenCalledTimes(0) - }) - - it('should work with static placeholderData', async () => { - const key = queryKey() - let suspenseRenderCount = 0 - let pageRenderCount = 0 - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - function Loading() { - suspenseRenderCount++ - - return <>loading.. - } - function Page() { - const query = useQuery({ - queryKey: key, - queryFn: async () => { - await sleep(1) - return 'test' - }, - placeholderData: 'placeholder', - }) - pageRenderCount++ - - return ( - }> - - - ) - } - - const rendered = renderWithClient(queryClient, ) - await waitFor(() => rendered.getByText('placeholder')) - await waitFor(() => rendered.getByText('test')) - - // Suspense boundary should never be rendered since it has data immediately - expect(suspenseRenderCount).toBe(0) - // Page should only be rendered twice since, the promise will get swapped out when new result comes in - expect(pageRenderCount).toBe(2) - }) - - it('should work with placeholderData: keepPreviousData', async () => { - const key = queryKey() - let suspenseRenderCount = 0 - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - function Loading() { - suspenseRenderCount++ - - return <>loading.. - } - function Page() { - const [count, setCount] = React.useState(0) - const query = useQuery({ - queryKey: [...key, count], - queryFn: async () => { - await sleep(1) - return 'test-' + count - }, - placeholderData: keepPreviousData, - }) - - return ( -
- }> - - - -
- ) - } - - const rendered = renderWithClient(queryClient, ) - await waitFor(() => rendered.getByText('loading..')) - await waitFor(() => rendered.getByText('test-0')) - - // Suspense boundary should only be rendered initially - expect(suspenseRenderCount).toBe(1) - - fireEvent.click(rendered.getByRole('button', { name: 'increment' })) - - await waitFor(() => rendered.getByText('test-1')) - - // no more suspense boundary rendering - expect(suspenseRenderCount).toBe(1) - }) - - it('should be possible to select a part of the data with select', async () => { - const key = queryKey() - let suspenseRenderCount = 0 - let pageRenderCount = 0 - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - return <>{data} - } - - function Loading() { - suspenseRenderCount++ - return <>loading.. - } - - function Page() { - const query = useQuery({ - queryKey: key, - queryFn: async () => { - await sleep(1) - return { name: 'test' } - }, - select: (data) => data.name, - }) - - pageRenderCount++ - return ( - }> - - - ) - } - - const rendered = renderWithClient(queryClient, ) - - await waitFor(() => { - rendered.getByText('test') - }) - expect(suspenseRenderCount).toBe(1) - expect(pageRenderCount).toBe(1) - }) - - it('should throw error if the promise fails', async () => { - let suspenseRenderCount = 0 - const consoleMock = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined) - - const key = queryKey() - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - - function Loading() { - suspenseRenderCount++ - return <>loading.. - } - - let queryCount = 0 - function Page() { - const query = useQuery({ - queryKey: key, - queryFn: async () => { - await sleep(1) - if (++queryCount > 1) { - // second time this query mounts, it should not throw - return 'data' - } - throw new Error('Error test') - }, - retry: false, - }) - - return ( - }> - - - ) - } - - const rendered = renderWithClient( - queryClient, - ( - <> - error boundary{' '} - - - )} - > - - , - ) - - await waitFor(() => rendered.getByText('loading..')) - await waitFor(() => rendered.getByText('error boundary')) - - consoleMock.mockRestore() - - fireEvent.click(rendered.getByText('resetErrorBoundary')) - - await waitFor(() => rendered.getByText('loading..')) - await waitFor(() => rendered.getByText('data')) - - expect(queryCount).toBe(2) - }) - - it('should recreate promise with data changes', async () => { - const key = queryKey() - let suspenseRenderCount = 0 - let pageRenderCount = 0 - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - - function Loading() { - suspenseRenderCount++ - return <>loading.. - } - function Page() { - const query = useQuery({ - queryKey: key, - queryFn: async () => { - await sleep(1) - return 'test1' - }, - }) - - pageRenderCount++ - return ( - }> - - - ) - } - - const rendered = renderWithClient(queryClient, ) - await waitFor(() => rendered.getByText('loading..')) - await waitFor(() => rendered.getByText('test1')) - - // Suspense should rendered once since `.promise` is the only watched property - expect(pageRenderCount).toBe(1) - - queryClient.setQueryData(key, 'test2') - - await waitFor(() => rendered.getByText('test2')) - - // Suspense should rendered once since `.promise` is the only watched property - expect(suspenseRenderCount).toBe(1) - - // Page should be rendered once since since the promise changed once - expect(pageRenderCount).toBe(2) - }) - - it('should dedupe when re-fetched with queryClient.fetchQuery while suspending', async () => { - const key = queryKey() - const queryFn = vi.fn().mockImplementation(async () => { - await sleep(10) - return 'test' - }) - - const options = { - queryKey: key, - queryFn, - } - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - - function Loading() { - return <>loading.. - } - function Page() { - const query = useQuery(options) - - return ( -
- }> - - - -
- ) - } - - const rendered = renderWithClient(queryClient, ) - fireEvent.click(rendered.getByText('fetch')) - await waitFor(() => rendered.getByText('loading..')) - await waitFor(() => rendered.getByText('test')) - - expect(queryFn).toHaveBeenCalledOnce() - }) - - it('should dedupe when re-fetched with refetchQueries while suspending', async () => { - const key = queryKey() - let count = 0 - const queryFn = vi.fn().mockImplementation(async () => { - await sleep(10) - return 'test' + count++ - }) - - const options = { - queryKey: key, - queryFn, - } - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - - function Loading() { - return <>loading.. - } - function Page() { - const query = useQuery(options) - - return ( -
- }> - - - -
- ) - } - - const rendered = renderWithClient(queryClient, ) - fireEvent.click(rendered.getByText('refetch')) - await waitFor(() => rendered.getByText('loading..')) - await waitFor(() => rendered.getByText('test0')) - - expect(queryFn).toHaveBeenCalledOnce() - }) - - it('should stay pending when canceled with cancelQueries while suspending until refetched', async () => { - const key = queryKey() - let count = 0 - const queryFn = vi.fn().mockImplementation(async () => { - await sleep(10) - return 'test' + count++ - }) - - const options = { - queryKey: key, - queryFn, - } - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - - function Loading() { - return <>loading.. - } - function Page() { - const query = useQuery(options) - - return ( -
- }> - - - - -
- ) - } - - const rendered = renderWithClient( - queryClient, - <>error boundary}> - - , - ) - fireEvent.click(rendered.getByText('cancel')) - await waitFor(() => rendered.getByText('loading..')) - // await waitFor(() => rendered.getByText('error boundary')) - await waitFor(() => - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'pending', - fetchStatus: 'idle', - }), - ) - - expect(queryFn).toHaveBeenCalledOnce() - - fireEvent.click(rendered.getByText('fetch')) - - await waitFor(() => rendered.getByText('hello')) - }) - - it('should resolve to previous data when canceled with cancelQueries while suspending', async () => { - const key = queryKey() - const queryFn = vi.fn().mockImplementation(async () => { - await sleep(10) - return 'test' - }) - - const options = { - queryKey: key, - queryFn, - } - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - - function Loading() { - return <>loading.. - } - function Page() { - const query = useQuery(options) - - return ( -
- }> - - - -
- ) - } - - queryClient.setQueryData(key, 'initial') - - const rendered = renderWithClient(queryClient, ) - fireEvent.click(rendered.getByText('cancel')) - await waitFor(() => rendered.getByText('initial')) - - expect(queryFn).toHaveBeenCalledTimes(1) - }) - - it('should suspend when not enabled', async () => { - const key = queryKey() - - const options = (count: number) => ({ - queryKey: [...key, count], - queryFn: async () => { - await sleep(10) - return 'test' + count - }, - }) - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - - function Loading() { - return <>loading.. - } - function Page() { - const [count, setCount] = React.useState(0) - const query = useQuery({ ...options(count), enabled: count > 0 }) - - return ( -
- }> - - - -
- ) - } - - const rendered = renderWithClient(queryClient, ) - await waitFor(() => rendered.getByText('loading..')) - fireEvent.click(rendered.getByText('enable')) - await waitFor(() => rendered.getByText('test1')) - }) - - it('should show correct data when read from cache only (staleTime)', async () => { - const key = queryKey() - let suspenseRenderCount = 0 - queryClient.setQueryData(key, 'initial') - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - - function Loading() { - suspenseRenderCount++ - return <>loading.. - } - function Page() { - const query = useQuery({ - queryKey: key, - queryFn: async () => { - await sleep(1) - return 'test' - }, - staleTime: Infinity, - }) - - return ( - }> - - - ) - } - - const rendered = renderWithClient(queryClient, ) - await waitFor(() => rendered.getByText('initial')) - - expect(suspenseRenderCount).toBe(0) - }) - - it('should show correct data when switching between cache entries without re-fetches', async () => { - const key = queryKey() - - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } - - function Loading() { - return <>loading.. - } - function Page() { - const [count, setCount] = React.useState(0) - const query = useQuery({ - queryKey: [key, count], - queryFn: async () => { - await sleep(10) - return 'test' + count - }, - staleTime: Infinity, - }) - - return ( -
- }> - - - - -
- ) - } - - const rendered = renderWithClient(queryClient, ) - await waitFor(() => rendered.getByText('loading..')) - await waitFor(() => rendered.getByText('test0')) - - fireEvent.click(rendered.getByText('inc')) - await waitFor(() => rendered.getByText('loading..')) - - await waitFor(() => rendered.getByText('test1')) - - console.log('---------dec------------') - fireEvent.click(rendered.getByText('dec')) - - await waitFor(() => rendered.getByText('test0')) - }) - }) })