diff --git a/.gitignore b/.gitignore index 9ad1f8224b..93fed3635a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,6 @@ size-plugin.json stats-hydration.json stats.json stats.html -.vscode/settings.json *.log *.tsbuildinfo diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..9bf4d12b52 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true +} diff --git a/examples/react/transition/.eslintrc b/examples/react/transition/.eslintrc new file mode 100644 index 0000000000..4e03b9e10b --- /dev/null +++ b/examples/react/transition/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": ["plugin:react/jsx-runtime", "plugin:react-hooks/recommended"] +} diff --git a/examples/react/transition/.gitignore b/examples/react/transition/.gitignore new file mode 100644 index 0000000000..4673b022e5 --- /dev/null +++ b/examples/react/transition/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +pnpm-lock.yaml +yarn.lock +package-lock.json + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/react/transition/README.md b/examples/react/transition/README.md new file mode 100644 index 0000000000..93f18812e1 --- /dev/null +++ b/examples/react/transition/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `pnpm install` +- `pnpm dev` diff --git a/examples/react/transition/index.html b/examples/react/transition/index.html new file mode 100644 index 0000000000..aca35c1a17 --- /dev/null +++ b/examples/react/transition/index.html @@ -0,0 +1,16 @@ + + + + + + + + + TanStack Query React Suspense Example App + + + +
+ + + diff --git a/examples/react/transition/package.json b/examples/react/transition/package.json new file mode 100644 index 0000000000..17d5de31b4 --- /dev/null +++ b/examples/react/transition/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/query-example-react-transition", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.62.8", + "@tanstack/react-query-devtools": "^5.62.8", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.3", + "typescript": "5.7.2", + "vite": "^5.3.5" + } +} diff --git a/examples/react/transition/public/emblem-light.svg b/examples/react/transition/public/emblem-light.svg new file mode 100644 index 0000000000..a58e69ad5e --- /dev/null +++ b/examples/react/transition/public/emblem-light.svg @@ -0,0 +1,13 @@ + + + + emblem-light + Created with Sketch. + + + + + + + + \ No newline at end of file diff --git a/examples/react/transition/src/index.tsx b/examples/react/transition/src/index.tsx new file mode 100755 index 0000000000..a8f03fee3b --- /dev/null +++ b/examples/react/transition/src/index.tsx @@ -0,0 +1,102 @@ +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query' +import { Suspense, use, useState, useTransition } from 'react' +import ReactDOM from 'react-dom/client' + +const Example1 = ({ value }: { value: number }) => { + const { isFetching, promise } = useQuery({ + queryKey: ['1' + value], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + return '1' + value + }, + }) + const data = use(promise) + + return ( +
+ {data} {isFetching ? 'fetching' : null} +
+ ) +} + +const Example2 = ({ value }: { value: number }) => { + const { promise, isFetching } = useQuery({ + queryKey: ['2' + value], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + return '2' + value + }, + // placeholderData: keepPreviousData, + }) + + const data = use(promise) + + return ( +
+ {data} {isFetching ? 'fetching' : null} +
+ ) +} + +const SuspenseBoundary = () => { + const [state, setState] = useState(-1) + const [isPending, startTransition] = useTransition() + + return ( +
+

Change state with transition

+
+ +
+

State:

+ +

2. 1 Suspense + startTransition

+ + + +

2.2 Suspense + startTransition

+ + + +
+ ) +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + experimental_prefetchInRender: true, + staleTime: 10 * 1000, + }, + }, +}) + +const App = () => { + return ( +
+ + + +
+ ) +} + +const rootElement = document.getElementById('root') as HTMLElement +ReactDOM.createRoot(rootElement).render() diff --git a/examples/react/transition/tsconfig.json b/examples/react/transition/tsconfig.json new file mode 100644 index 0000000000..23a8707ef4 --- /dev/null +++ b/examples/react/transition/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "eslint.config.js"] +} diff --git a/examples/react/transition/vite.config.ts b/examples/react/transition/vite.config.ts new file mode 100644 index 0000000000..9ffcc67574 --- /dev/null +++ b/examples/react/transition/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 3dc751f5cd..e360f571ce 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -698,11 +698,11 @@ export class QueryObserver< includedProps.add('error') } - return Object.keys(this.#currentResult).some((key) => { + return [...includedProps].some((key) => { const typedKey = key as keyof QueryObserverResult const changed = this.#currentResult[typedKey] !== prevResult[typedKey] - return changed && includedProps.has(typedKey) + return changed }) } diff --git a/packages/react-query/src/__tests__/transition.test.tsx b/packages/react-query/src/__tests__/transition.test.tsx new file mode 100644 index 0000000000..833b4bde41 --- /dev/null +++ b/packages/react-query/src/__tests__/transition.test.tsx @@ -0,0 +1,288 @@ +/* eslint-disable @typescript-eslint/require-await */ +import { act, render, screen } from '@testing-library/react' +import * as React from 'react' +import { afterAll, beforeAll, expect, it, vi } from 'vitest' +import { QueryClientProvider, useQuery } from '..' +import { QueryCache } from '../index' +import { createQueryClient, queryKey, sleep } from './utils' + +const queryCache = new QueryCache() +const queryClient = createQueryClient({ + queryCache, +}) + +beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + global.IS_REACT_ACT_ENVIRONMENT = true + queryClient.setDefaultOptions({ + queries: { experimental_prefetchInRender: true }, + }) + vi.useFakeTimers() +}) +afterAll(() => { + queryClient.setDefaultOptions({ + queries: { experimental_prefetchInRender: false }, + }) + vi.useRealTimers() +}) + +it('should keep values of old key around with startTransition', async () => { + const key = queryKey() + + function Loading() { + return <>loading... + } + + function Page() { + const [isPending, startTransition] = React.useTransition() + const [count, setCount] = React.useState(0) + const query = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(10) + return 'test' + count + }, + staleTime: 1000, + }) + + const data = React.use(query.promise) + + return ( +
+ +
data: {data}
+ {isPending && pending...} +
+ ) + } + // Initial render should show fallback + await act(async () => { + render( + + }> + + + , + ) + }) + + screen.getByText('loading...') + expect(screen.queryByText('button')).toBeNull() + expect(screen.queryByText('pending...')).toBeNull() + expect(screen.queryByText('data: test0')).toBeNull() + + // Resolve the query, should show the data + await act(async () => { + vi.runAllTimers() + }) + + expect(screen.queryByText('loading...')).toBeNull() + screen.getByRole('button') + expect(screen.queryByText('pending...')).toBeNull() + screen.getByText('data: test0') + + // Update in a transition, should show pending state, and existing content + await act(async () => { + screen.getByRole('button', { name: 'increment' }).click() + }) + expect(screen.queryByText('loading...')).toBeNull() + screen.getByRole('button') + screen.getByText('pending...') + screen.getByText('data: test0') + + // Resolve the query, should show the new data and no pending state + await act(async () => { + vi.runAllTimers() + }) + expect(screen.queryByText('loading...')).toBeNull() + screen.getByRole('button') + expect(screen.queryByText('pending...')).toBeNull() + screen.getByText('data: test1') +}) + +it('should handle parallel queries with shared parent key in transition', async () => { + function ComponentA(props: { parentId: number }) { + const query = useQuery({ + queryKey: ['A', props.parentId], + queryFn: async () => { + await sleep(10) + return `A-${props.parentId}` + }, + staleTime: 1000, + }) + + const data = React.use(query.promise) + return
A data: {data}
+ } + + function ComponentALoading() { + return
A loading...
+ } + + function ComponentB(props: { parentId: number }) { + const query = useQuery({ + queryKey: ['B', props.parentId], + queryFn: async () => { + await sleep(10) + return `B-${props.parentId}` + }, + staleTime: 1000, + }) + + const data = React.use(query.promise) + return
B data: {data}
+ } + + function ComponentBLoading() { + return
B loading...
+ } + + function Parent() { + const [count, setCount] = React.useState(0) + const [isPending, startTransition] = React.useTransition() + return ( +
+ + }> + + + }> + + + {isPending && pending...} +
+ ) + } + + // Initial render should show fallback + await act(async () => { + render( + + + , + ) + }) + + screen.getByText('A loading...') + screen.getByText('B loading...') + + // Resolve the query, should show the data + await act(async () => { + vi.runAllTimers() + }) + + screen.getByText('A data: A-0') + screen.getByText('B data: B-0') + + // Update in a transition, should show pending state, and existing content + await act(async () => { + screen.getByRole('button', { name: 'increment' }).click() + }) + + screen.getByText('pending...') + screen.getByText('A data: A-0') + screen.getByText('B data: B-0') + + // Resolve the query, should show the new data and no pending state + await act(async () => { + vi.runAllTimers() + }) + screen.getByText('A data: A-1') + screen.getByText('B data: B-1') + expect(screen.queryByText('pending...')).toBeNull() +}) + +it('should work to interrupt a transition', async () => { + const resolversByCount: Record void> = {} + + const key = queryKey() + + function Component(props: { count: number }) { + const { count } = props + + const query = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await new Promise((resolve) => { + resolversByCount[count] = resolve + }) + + return 'test' + count + }, + staleTime: 1000, + }) + const data = React.use(query.promise) + return
data: {data}
+ } + + function Page() { + const [isPending, startTransition] = React.useTransition() + const [count, setCount] = React.useState(0) + + return ( +
+ + + + + {isPending && 'pending...'} +
+ ) + } + // Initial render should show fallback + await act(async () => { + render( + + + , + ) + }) + + screen.getByText('loading...') + expect(screen.queryByText('button')).toBeNull() + expect(screen.queryByText('pending...')).toBeNull() + expect(screen.queryByText('data: test0')).toBeNull() + + // Resolve the query, should show the data + await act(async () => { + resolversByCount[0]!() + }) + + screen.getByText('data: test0') + + // increment + await act(async () => { + screen.getByRole('button', { name: 'increment' }).click() + }) + + // should show pending state, and existing content + screen.getByText('pending...') + screen.getByText('data: test0') + + // Before the query is resolved, increment again + await act(async () => { + screen.getByRole('button', { name: 'increment' }).click() + }) + + await act(async () => { + // resolve the second query + resolversByCount[1]!() + }) + + screen.getByText('pending...') + screen.getByText('data: test0') + + await act(async () => { + // resolve the third query + resolversByCount[2]!() + }) + + screen.getByText('data: test2') +}) diff --git a/packages/react-query/src/__tests__/useQuery.promise.test.tsx b/packages/react-query/src/__tests__/useQuery.promise.test.tsx index ec07d2d2b2..100e0fa1ce 100644 --- a/packages/react-query/src/__tests__/useQuery.promise.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.promise.test.tsx @@ -1,10 +1,12 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import * as React from 'react' -import { ErrorBoundary } from 'react-error-boundary' import { createRenderStream, + disableActEnvironment, useTrackRenders, } from '@testing-library/react-render-stream' +import * as React from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, waitFor } from '@testing-library/react' import { QueryClientProvider, QueryErrorResetBoundary, @@ -12,7 +14,17 @@ import { useQuery, } from '..' import { QueryCache } from '../index' -import { createQueryClient, queryKey, sleep } from './utils' +import { createQueryClient, queryKey, renderWithClient, sleep } from './utils' + +function createDeferred() { + let resolve: (value: T) => void + let reject: (error: unknown) => void + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve + reject = _reject + }) + return { promise, resolve: resolve!, reject: reject! } +} describe('useQuery().promise', () => { const queryCache = new QueryCache() @@ -31,1350 +43,1453 @@ describe('useQuery().promise', () => { }) }) - it('should work with a basic test', async () => { - const key = queryKey() + describe('testing lib tests', () => { + it('should throw error if query fails with deferred value #8249', async () => { + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + const key = queryKey() + let renderCount = 0 + + function Page() { + renderCount++ + + const [_count, setCount] = React.useState(0) + const count = React.useDeferredValue(_count) + + const query = useQuery({ + queryKey: [key, count], + queryFn: async () => { + await sleep(10) + // succeed only on first query + if (count === 0) { + return 'test' + count + } + throw new Error('Error test') + }, + retry: false, + }) + + return ( + + + + + ) + } + + const rendered = await act(() => + renderWithClient( + queryClient, +
error boundary
}> + +
, + ), + ) - const renderStream = createRenderStream({ snapshotDOM: true }) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test0')) - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - useTrackRenders() - return <>{data} - } + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) - function Loading() { - useTrackRenders() - return <>loading.. - } + await act(() => fireEvent.click(rendered.getByText('inc'))) - function Page() { - useTrackRenders() - const query = useQuery({ - queryKey: key, - queryFn: async () => { - await sleep(1) - return 'test' - }, - }) + await waitFor(() => rendered.getByText('error boundary')) - return ( - }> - - - ) - } - - await renderStream.render( - - - , - ) - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - expect(renderedComponents).toEqual([Page, Loading]) - } - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test') - expect(renderedComponents).toEqual([Page, MyComponent]) - } + consoleMock.mockRestore() + + expect(renderCount).toBe(6) + }) }) - it('colocate suspense and promise', async () => { - const key = queryKey() - let callCount = 0 + describe('renderStream tests', () => { + let disableActReturn: ReturnType + beforeAll(() => { + disableActReturn = disableActEnvironment() + }) + afterAll(() => { + disableActReturn.cleanup() + }) - const renderStream = createRenderStream({ snapshotDOM: true }) + it('should work with a basic test', async () => { + const key = queryKey() + + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + useTrackRenders() + return <>{data} + } + + function Loading() { + useTrackRenders() + return <>loading.. + } + + function Page() { + useTrackRenders() + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return 'test' + }, + }) + + return ( + }> + + + ) + } - function MyComponent() { - useTrackRenders() - 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() { - useTrackRenders() - return <>loading.. - } - function Page() { - useTrackRenders() - return ( - }> - - + await renderStream.render( + + + , ) - } - - await renderStream.render( - - - , - ) - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - expect(renderedComponents).toEqual([Page, Loading]) - } - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test') - expect(renderedComponents).toEqual([MyComponent]) - } - - expect(callCount).toBe(1) - }) - it('parallel queries', async () => { - const key = queryKey() - const renderStream = createRenderStream({ snapshotDOM: true }) - let callCount = 0 + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('test') + expect(renderedComponents).toEqual([MyComponent]) + } + }) - function MyComponent() { - useTrackRenders() - 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() { - useTrackRenders() - return <>loading.. - } - function Page() { - useTrackRenders() - return ( - <> + it('colocate suspense and promise', async () => { + const key = queryKey() + let callCount = 0 + + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent() { + useTrackRenders() + 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() { + useTrackRenders() + return <>loading.. + } + function Page() { + useTrackRenders() + return ( }> - - - - - - - - ) - } - - await renderStream.render( - - - , - ) - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - expect(renderedComponents).toEqual([Page, Loading]) - } - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('testtesttesttesttest') - expect(renderedComponents).toEqual([ - MyComponent, - MyComponent, - MyComponent, - MyComponent, - MyComponent, - ]) - } - - expect(callCount).toBe(1) - }) - - it('should work with initial data', async () => { - const key = queryKey() - const renderStream = createRenderStream({ snapshotDOM: true }) - - function MyComponent(props: { promise: Promise }) { - useTrackRenders() - const data = React.use(props.promise) - - return <>{data} - } - function Loading() { - useTrackRenders() - - return <>loading.. - } - function Page() { - useTrackRenders() - const query = useQuery({ - queryKey: key, - queryFn: async () => { - await sleep(1) - return 'test' - }, - initialData: 'initial', - }) + ) + } - return ( - }> - - + await renderStream.render( + + + , ) - } - - await renderStream.render( - - - , - ) - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('initial') - expect(renderedComponents).toEqual([Page, MyComponent]) - } - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test') - expect(renderedComponents).toEqual([Page, MyComponent]) - } - }) - it('should not fetch with initial data and staleTime', async () => { - const key = queryKey() - const renderStream = createRenderStream({ snapshotDOM: true }) - const queryFn = vi.fn().mockImplementation(async () => { - await sleep(1) - return 'test' + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('test') + expect(renderedComponents).toEqual([MyComponent]) + } + + expect(callCount).toBe(1) }) - function MyComponent(props: { promise: Promise }) { - useTrackRenders() - const data = React.use(props.promise) - - return <>{data} - } - function Loading() { - useTrackRenders() - return <>loading.. - } - function Page() { - useTrackRenders() - const query = useQuery({ - queryKey: key, - queryFn, - initialData: 'initial', - staleTime: 1000, - }) - - return ( - }> - - + it('parallel queries', async () => { + const deferred = createDeferred() + const key = queryKey() + + const renderStream = createRenderStream({ snapshotDOM: true }) + let callCount = 0 + + function MyComponent() { + useTrackRenders() + const query = useQuery({ + queryKey: key, + queryFn: async () => { + callCount++ + await deferred.promise + return 'test' + }, + staleTime: 1000, + }) + const data = React.use(query.promise) + + return data + } + + function Loading() { + useTrackRenders() + return loading.. + } + function Page() { + useTrackRenders() + return ( + <> + }> + + + + + loading 2...}> + + + + + ) + } + + await renderStream.render( + + + , ) - } - - await renderStream.render( - - - , - ) - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('initial') - expect(renderedComponents).toEqual([Page, MyComponent]) - } - - // should not call queryFn because of staleTime + initialData combo - expect(queryFn).toHaveBeenCalledTimes(0) - }) - - it('should work with static placeholderData', async () => { - const key = queryKey() - const renderStream = createRenderStream({ snapshotDOM: true }) - function MyComponent(props: { promise: Promise }) { - useTrackRenders() - const data = React.use(props.promise) + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('loading..') + withinDOM().getByText('loading 2...') + expect(renderedComponents).toEqual([Page, Loading]) + } - return <>{data} - } - function Loading() { - useTrackRenders() - - return <>loading.. - } - function Page() { - const query = useQuery({ - queryKey: key, - queryFn: async () => { - await sleep(1) - return 'test' - }, - placeholderData: 'placeholder', - }) - useTrackRenders() + deferred.resolve() - return ( - }> - - - ) - } - - await renderStream.render( - - - , - ) - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('placeholder') - expect(renderedComponents).toEqual([Page, MyComponent]) - } - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test') - expect(renderedComponents).toEqual([Page, MyComponent]) - } - }) + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('testtesttesttesttest') + } - it('should work with placeholderData: keepPreviousData', async () => { - const key = queryKey() - const renderStream = createRenderStream({ snapshotDOM: true }) - - function MyComponent(props: { promise: Promise }) { - useTrackRenders() - const data = React.use(props.promise) - - return <>{data} - } - function Loading() { - useTrackRenders() - - return <>loading.. - } - function Page() { - useTrackRenders() - const [count, setCount] = React.useState(0) - const query = useQuery({ - queryKey: [...key, count], - queryFn: async () => { - await sleep(1) - return 'test-' + count - }, - placeholderData: keepPreviousData, - }) + expect(callCount).toBe(1) + }) - return ( -
+ it('should work with initial data', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + useTrackRenders() + + return <>loading.. + } + function Page() { + useTrackRenders() + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return 'test' + }, + initialData: 'initial', + }) + + return ( }> - -
- ) - } - - const rendered = await renderStream.render( - - - , - ) - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - expect(renderedComponents).toEqual([Page, Loading]) - } - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test-0') - expect(renderedComponents).toEqual([MyComponent]) - } - - rendered.getByRole('button', { name: 'increment' }).click() - - // re-render because of the increment - { - const { renderedComponents } = await renderStream.takeRender() - expect(renderedComponents).toEqual([Page, MyComponent]) - } - - // re-render with new data, no loading between - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test-1') - // no more suspense boundary rendering - expect(renderedComponents).toEqual([Page, MyComponent]) - } - }) - - it('should be possible to select a part of the data with select', async () => { - const key = queryKey() - const renderStream = createRenderStream({ snapshotDOM: true }) + ) + } - function MyComponent(props: { promise: Promise }) { - useTrackRenders() - const data = React.use(props.promise) - return <>{data} - } - - function Loading() { - useTrackRenders() - return <>loading.. - } - - function Page() { - const query = useQuery({ - queryKey: key, - queryFn: async () => { - await sleep(1) - return { name: 'test' } - }, - select: (data) => data.name, - }) - - useTrackRenders() - return ( - }> - - - ) - } - - await renderStream.render( - - - , - ) - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - expect(renderedComponents).toEqual([Page, Loading]) - } - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test') - expect(renderedComponents).toEqual([MyComponent]) - } - }) - - it('should throw error if the promise fails', async () => { - const renderStream = createRenderStream({ snapshotDOM: true }) - 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() { - 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 ( - }> - - + await renderStream.render( + + + , ) - } - - const rendered = await renderStream.render( - - - {({ reset }) => ( - ( -
-
error boundary
- -
- )} - > - -
- )} -
-
, - ) - - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - } - - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('error boundary') - } - - consoleMock.mockRestore() - - rendered.getByText('resetErrorBoundary').click() - - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - } - - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('data') - } - - expect(queryCount).toBe(2) - }) - it('should throw error if the promise fails (colocate suspense and promise)', async () => { - const renderStream = createRenderStream({ snapshotDOM: true }) - const consoleMock = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined) - - const key = queryKey() + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('initial') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('test') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + }) - function MyComponent() { - const query = useQuery({ - queryKey: key, - queryFn: async () => { - await sleep(1) - throw new Error('Error test') - }, - retry: false, + it('should not fetch with initial data and staleTime', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(1) + return 'test' }) - const data = React.use(query.promise) - return <>{data} - } + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + useTrackRenders() + return <>loading.. + } + function Page() { + useTrackRenders() + const query = useQuery({ + queryKey: key, + queryFn, + initialData: 'initial', + staleTime: 1000, + }) + + return ( + }> + + + ) + } - function Page() { - return ( - - - + await renderStream.render( + + + , ) - } - await renderStream.render( - -
error boundary
}> - -
-
, - ) + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('initial') + expect(renderedComponents).toEqual([Page, MyComponent]) + } - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - } + // should not call queryFn because of staleTime + initialData combo + expect(queryFn).toHaveBeenCalledTimes(0) + }) - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('error boundary') - } + it('should work with static placeholderData', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + useTrackRenders() + + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return 'test' + }, + placeholderData: 'placeholder', + }) + useTrackRenders() + + return ( + }> + + + ) + } - consoleMock.mockRestore() - }) + await renderStream.render( + + + , + ) - it('should recreate promise with data changes', async () => { - const key = queryKey() - const renderStream = createRenderStream({ snapshotDOM: true }) + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('placeholder') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('test') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + }) - function MyComponent(props: { promise: Promise }) { - useTrackRenders() - const data = React.use(props.promise) + it('should work with placeholderData: keepPreviousData', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + useTrackRenders() + + return <>loading.. + } + function Page() { + useTrackRenders() + 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 = await renderStream.render( + + + , + ) - return <>{data} - } + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('test-0') + expect(renderedComponents).toEqual([MyComponent]) + } + + rendered.getByRole('button', { name: 'increment' }).click() + + // re-render because of the increment + { + const { renderedComponents } = await renderStream.takeRender() + expect(renderedComponents).toEqual([Page, MyComponent]) + } + + // re-render with new data, no loading between + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('test-1') + // no more suspense boundary rendering + expect(renderedComponents).toEqual([Page, MyComponent]) + } + }) - function Loading() { - useTrackRenders() - return <>loading.. - } - function Page() { - const query = useQuery({ - queryKey: key, - queryFn: async () => { - await sleep(1) - return 'test1' - }, - }) + it('should be possible to select a part of the data with select', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + return <>{data} + } + + function Loading() { + useTrackRenders() + return <>loading.. + } + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return { name: 'test' } + }, + select: (data) => data.name, + }) + + useTrackRenders() + return ( + }> + + + ) + } - useTrackRenders() - return ( - }> - - + await renderStream.render( + + + , ) - } - - await renderStream.render( - - - , - ) - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - expect(renderedComponents).toEqual([Page, Loading]) - } - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test1') - expect(renderedComponents).toEqual([MyComponent]) - } - - queryClient.setQueryData(key, 'test2') - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test2') - expect(renderedComponents).toEqual([Page, MyComponent]) - } - }) - it('should dedupe when re-fetched with queryClient.fetchQuery while suspending', async () => { - const key = queryKey() - const renderStream = createRenderStream({ snapshotDOM: true }) - const queryFn = vi.fn().mockImplementation(async () => { - await sleep(10) - return 'test' + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('test') + expect(renderedComponents).toEqual([MyComponent]) + } }) - const options = { - queryKey: key, - queryFn, - } + it('should throw error if the promise fails', async () => { + let deferred = createDeferred() + const renderStream = createRenderStream({ snapshotDOM: true }) + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) + const key = queryKey() + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) - return <>{data} - } + return <>{data} + } - function Loading() { - return <>loading.. - } - function Page() { - const query = useQuery(options) + function Loading() { + return <>loading.. + } - return ( -
+ let queryCount = 0 + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + queryCount++ + await deferred.promise + + return 'data' + }, + retry: false, + }) + + return ( }> - -
+ ) + } + + const rendered = await renderStream.render( + + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
+
, ) - } - const rendered = await renderStream.render( - - - , - ) + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - } + deferred.reject(new Error('Error test')) + deferred = createDeferred() - rendered.getByText('fetch').click() + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('error boundary') + } - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test') - } + consoleMock.mockRestore() - expect(queryFn).toHaveBeenCalledOnce() - }) + rendered.getByText('resetErrorBoundary').click() - it('should dedupe when re-fetched with refetchQueries while suspending', async () => { - const key = queryKey() - let count = 0 - const renderStream = createRenderStream({ snapshotDOM: true }) - const queryFn = vi.fn().mockImplementation(async () => { - await sleep(10) - return 'test' + count++ + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + deferred.resolve() + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('data') + } + + expect(queryCount).toBe(2) }) - const options = { - queryKey: key, - queryFn, - } + it('should throw error if the promise fails (colocate suspense and promise)', async () => { + const deferred = createDeferred() + const renderStream = createRenderStream({ snapshotDOM: true }) + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) + const key = queryKey() - return <>{data} - } + function MyComponent() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await deferred.promise + return 'data' + }, + retry: false, + }) + const data = React.use(query.promise) - function Loading() { - return <>loading.. - } - function Page() { - const query = useQuery(options) + return <>{data} + } - return ( -
- }> - + function Page() { + return ( + + - -
+ ) + } + + await renderStream.render( + +
error boundary
}> + +
+
, ) - } - - const rendered = await renderStream.render( - - - , - ) - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - } + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } - rendered.getByText('refetch').click() + deferred.reject(new Error('Error test')) - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test0') - } + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('error boundary') + } - expect(queryFn).toHaveBeenCalledOnce() - }) - - it('should stay pending when canceled with cancelQueries while suspending until refetched', async () => { - const renderStream = createRenderStream({ snapshotDOM: true }) - const key = queryKey() - let count = 0 - const queryFn = vi.fn().mockImplementation(async () => { - await sleep(10) - return 'test' + count++ + consoleMock.mockRestore() }) - 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 ( -
+ it('should recreate promise with data changes', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + useTrackRenders() + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return 'test1' + }, + }) + + useTrackRenders() + return ( }> - - -
- ) - } + ) + } - const rendered = await renderStream.render( - - <>error boundary}> + await renderStream.render( + - - , - ) - - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - } + , + ) - rendered.getByText('cancel').click() + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('test1') + expect(renderedComponents).toEqual([MyComponent]) + } + + queryClient.setQueryData(key, 'test2') + + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('test2') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + }) - { - await renderStream.takeRender() - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'pending', - fetchStatus: 'idle', + it('should dedupe when re-fetched with queryClient.fetchQuery while suspending', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(10) + return 'test' }) - } - expect(queryFn).toHaveBeenCalledOnce() - - rendered.getByText('fetch').click() - - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('hello') - } - }) + 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 = await renderStream.render( + + + , + ) - it('should resolve to previous data when canceled with cancelQueries while suspending', async () => { - const renderStream = createRenderStream({ snapshotDOM: true }) - const key = queryKey() - const queryFn = vi.fn().mockImplementation(async () => { - await sleep(10) - return 'test' - }) + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } - const options = { - queryKey: key, - queryFn, - } + rendered.getByText('fetch').click() - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test') + } - return <>{data} - } + expect(queryFn).toHaveBeenCalledOnce() + }) - function Loading() { - return <>loading.. - } - function Page() { - const query = useQuery(options) + it('should dedupe when re-fetched with refetchQueries while suspending', async () => { + const key = queryKey() + let count = 0 + const renderStream = createRenderStream({ snapshotDOM: true }) + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(10) + return 'test' + count++ + }) - return ( -
- }> - - - -
+ 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 = await renderStream.render( + + + , ) - } - - queryClient.setQueryData(key, 'initial') - const rendered = await renderStream.render( - - - , - ) + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } - rendered.getByText('cancel').click() + rendered.getByText('refetch').click() - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('initial') - } + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test0') + } - expect(queryFn).toHaveBeenCalledTimes(1) - }) - - it('should suspend when not enabled', async () => { - const renderStream = createRenderStream({ snapshotDOM: true }) - const key = queryKey() - - const options = (count: number) => ({ - queryKey: [...key, count], - queryFn: async () => { - await sleep(10) - return 'test' + count - }, + expect(queryFn).toHaveBeenCalledOnce() }) - 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 }) + it('should stay pending when canceled with cancelQueries while suspending until refetched', async () => { + const renderStream = createRenderStream({ snapshotDOM: true }) + const key = queryKey() + let count = 0 + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(10) + return 'test' + count++ + }) - return ( -
- }> - - - -
+ 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 = await renderStream.render( + + <>error boundary}> + + + , ) - } - const rendered = await renderStream.render( - - - , - ) + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - } + rendered.getByText('cancel').click() - rendered.getByText('enable').click() + { + await renderStream.takeRender() + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'pending', + fetchStatus: 'idle', + }) + } - // loading re-render with enabled - await renderStream.takeRender() + expect(queryFn).toHaveBeenCalledOnce() - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test1') - } - }) + rendered.getByText('fetch').click() - it('should show correct data when read from cache only (staleTime)', async () => { - const key = queryKey() - const renderStream = createRenderStream({ snapshotDOM: true }) - queryClient.setQueryData(key, 'initial') - - const queryFn = vi.fn().mockImplementation(async () => { - await sleep(1) - return 'test' + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('hello') + } }) - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) - - return <>{data} - } + it('should resolve to previous data when canceled with cancelQueries while suspending', async () => { + const renderStream = createRenderStream({ snapshotDOM: true }) + const key = queryKey() + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(10) + return 'test' + }) - function Loading() { - return <>loading.. - } - function Page() { - const query = useQuery({ + const options = { queryKey: key, queryFn, - staleTime: Infinity, - }) - - return ( - }> - - + } + + 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 = await renderStream.render( + + + , ) - } - await renderStream.render( - - - , - ) + rendered.getByText('cancel').click() - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('initial') - } + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('initial') + } - expect(queryFn).toHaveBeenCalledTimes(0) - }) + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should suspend when not enabled', async () => { + const renderStream = createRenderStream({ snapshotDOM: true }) + const key = queryKey() - it('should show correct data when switching between cache entries without re-fetches', async () => { - const key = queryKey() - const renderStream = createRenderStream({ snapshotDOM: true }) - - function MyComponent(props: { promise: Promise }) { - useTrackRenders() - const data = React.use(props.promise) - - return <>{data} - } - - function Loading() { - useTrackRenders() - return <>loading.. - } - function Page() { - useTrackRenders() - const [count, setCount] = React.useState(0) - const query = useQuery({ - queryKey: [key, count], + const options = (count: number) => ({ + queryKey: [...key, count], queryFn: async () => { await sleep(10) return 'test' + count }, - staleTime: Infinity, }) - return ( -
- }> - - - - -
+ 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 = await renderStream.render( + + + , ) - } - - const rendered = await renderStream.render( - - - , - ) - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - expect(renderedComponents).toEqual([Page, Loading]) - } - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test0') - expect(renderedComponents).toEqual([MyComponent]) - } - - rendered.getByText('inc').click() - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - expect(renderedComponents).toEqual([Page, Loading]) - } - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test1') - expect(renderedComponents).toEqual([MyComponent]) - } - - rendered.getByText('dec').click() - - { - const { renderedComponents, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test0') - expect(renderedComponents).toEqual([Page, MyComponent]) - } - }) - it('should not resolve with intermediate data when keys are switched', async () => { - const key = queryKey() - const renderStream = createRenderStream<{ data: string }>({ - snapshotDOM: true, - }) + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) + rendered.getByText('enable').click() - renderStream.replaceSnapshot({ data }) + // loading re-render with enabled + await renderStream.takeRender() - return <>{data} - } + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test1') + } + }) - 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, + it('should show correct data when read from cache only (staleTime)', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + queryClient.setQueryData(key, 'initial') + + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(1) + return 'test' }) - return ( -
+ function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn, + staleTime: Infinity, + }) + + return ( }> - -
+ ) + } + + await renderStream.render( + + + , ) - } - - const rendered = await renderStream.render( - - - , - ) - - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - } - - { - const { snapshot, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test0') - expect(snapshot).toMatchObject({ data: 'test0' }) - } - - rendered.getByText('inc').click() - - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - } - - rendered.getByText('inc').click() - await renderStream.takeRender() - - rendered.getByText('inc').click() - await renderStream.takeRender() - - { - const { snapshot, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test3') - expect(snapshot).toMatchObject({ data: 'test3' }) - } - }) - it('should not resolve with intermediate data when keys are switched (with background updates)', async () => { - const key = queryKey() - const renderStream = createRenderStream<{ data: string }>({ - snapshotDOM: true, - }) - let modifier = '' + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('initial') + } - function MyComponent(props: { promise: Promise }) { - const data = React.use(props.promise) + expect(queryFn).toHaveBeenCalledTimes(0) + }) - renderStream.replaceSnapshot({ data }) + it('should show correct data when switching between cache entries without re-fetches', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent(props: { promise: Promise }) { + useTrackRenders() + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + useTrackRenders() + return <>loading.. + } + function Page() { + useTrackRenders() + 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 = await renderStream.render( + + + , + ) - return <>{data} - } + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('test0') + expect(renderedComponents).toEqual([MyComponent]) + } + + rendered.getByText('inc').click() + + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(renderedComponents).toEqual([Page, Loading]) + } + + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('test1') + expect(renderedComponents).toEqual([MyComponent]) + } + + rendered.getByText('dec').click() + + { + const { renderedComponents, withinDOM } = + await renderStream.takeRender() + withinDOM().getByText('test0') + expect(renderedComponents).toEqual([Page, MyComponent]) + } + }) - 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 - }, + it('should not resolve with intermediate data when keys are switched', async () => { + const key = queryKey() + const renderStream = createRenderStream<{ data: string }>({ + snapshotDOM: true, }) - return ( -
- }> - - - - -
+ function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + renderStream.replaceSnapshot({ 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 = await renderStream.render( + + + , ) - } - - const rendered = await renderStream.render( - - - , - ) - - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - } - - { - const { snapshot, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test0') - expect(snapshot).toMatchObject({ data: 'test0' }) - } - - rendered.getByText('inc').click() - { - const { snapshot } = await renderStream.takeRender() - expect(snapshot).toMatchObject({ data: 'test0' }) - } - - rendered.getByText('inc').click() - { - const { snapshot } = await renderStream.takeRender() - expect(snapshot).toMatchObject({ data: 'test0' }) - } - - rendered.getByText('inc').click() - - { - const { snapshot, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - expect(snapshot).toMatchObject({ data: 'test0' }) - } - - { - const { snapshot, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test3') - expect(snapshot).toMatchObject({ data: 'test3' }) - } - - modifier = 'new' - - rendered.getByText('dec').click() - { - const { snapshot } = await renderStream.takeRender() - expect(snapshot).toMatchObject({ data: 'test2' }) - } - - rendered.getByText('dec').click() - { - const { snapshot } = await renderStream.takeRender() - expect(snapshot).toMatchObject({ data: 'test1' }) - } - - rendered.getByText('dec').click() - { - const { snapshot } = await renderStream.takeRender() - expect(snapshot).toMatchObject({ data: 'test0' }) - } - - { - const { snapshot, withinDOM } = await renderStream.takeRender() - withinDOM().getByText('test0new') - expect(snapshot).toMatchObject({ data: 'test0new' }) - } - }) - it('should not suspend indefinitely with multiple, nested observers)', async () => { - const key = queryKey() - const renderStream = createRenderStream({ snapshotDOM: true }) + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } - function MyComponent({ input }: { input: string }) { - const query = useTheQuery(input) - const data = React.use(query.promise) + { + const { snapshot, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test0') + expect(snapshot).toMatchObject({ data: 'test0' }) + } - return <>{data} - } + rendered.getByText('inc').click() - function useTheQuery(input: string) { - return useQuery({ - staleTime: Infinity, - queryKey: [key, input], - queryFn: async () => { - await sleep(1) - return input + ' response' - }, + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + rendered.getByText('inc').click() + await renderStream.takeRender() + + rendered.getByText('inc').click() + await renderStream.takeRender() + + { + const { snapshot, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test3') + expect(snapshot).toMatchObject({ data: 'test3' }) + } + }) + + it('should not resolve with intermediate data when keys are switched (with background updates)', async () => { + const key = queryKey() + const renderStream = createRenderStream<{ data: string }>({ + snapshotDOM: true, }) - } + let modifier = '' + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + renderStream.replaceSnapshot({ 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 = await renderStream.render( + + + , + ) - function Page() { - const [input, setInput] = React.useState('defaultInput') - useTheQuery(input) + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + { + const { snapshot, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test0') + expect(snapshot).toMatchObject({ data: 'test0' }) + } + + rendered.getByText('inc').click() + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ data: 'test0' }) + } + + rendered.getByText('inc').click() + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ data: 'test0' }) + } + + rendered.getByText('inc').click() + + { + const { snapshot, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + expect(snapshot).toMatchObject({ data: 'test0' }) + } + + { + const { snapshot, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test3') + expect(snapshot).toMatchObject({ data: 'test3' }) + } + + modifier = 'new' + + rendered.getByText('dec').click() + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ data: 'test2' }) + } + + rendered.getByText('dec').click() + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ data: 'test1' }) + } + + rendered.getByText('dec').click() + { + const { snapshot } = await renderStream.takeRender() + expect(snapshot).toMatchObject({ data: 'test0' }) + } + + { + const { snapshot, withinDOM } = await renderStream.takeRender() + withinDOM().getByText('test0new') + expect(snapshot).toMatchObject({ data: 'test0new' }) + } + }) - return ( -
- - - - -
+ it('should not suspend indefinitely with multiple, nested observers)', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + function MyComponent({ input }: { input: string }) { + const query = useTheQuery(input) + const data = React.use(query.promise) + + return <>{data} + } + + function useTheQuery(input: string) { + return useQuery({ + staleTime: Infinity, + queryKey: [key, input], + queryFn: async () => { + await sleep(1) + return input + ' response' + }, + }) + } + + function Page() { + const [input, setInput] = React.useState('defaultInput') + useTheQuery(input) + + return ( +
+ + + + +
+ ) + } + + const rendered = await renderStream.render( + + + , ) - } - - const rendered = await renderStream.render( - - - , - ) - - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - } - - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('defaultInput response') - } - - expect( - queryClient.getQueryCache().find({ queryKey: [key, 'defaultInput'] })! - .observers.length, - ).toBe(2) - - rendered.getByText('setInput').click() - - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('loading..') - } - - { - const { withinDOM } = await renderStream.takeRender() - withinDOM().getByText('someInput response') - } - - expect( - queryClient.getQueryCache().find({ queryKey: [key, 'defaultInput'] })! - .observers.length, - ).toBe(0) - - expect( - queryClient.getQueryCache().find({ queryKey: [key, 'someInput'] })! - .observers.length, - ).toBe(2) + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('defaultInput response') + } + + expect( + queryClient.getQueryCache().find({ queryKey: [key, 'defaultInput'] })! + .observers.length, + ).toBe(2) + + rendered.getByText('setInput').click() + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('loading..') + } + + { + const { withinDOM } = await renderStream.takeRender() + withinDOM().getByText('someInput response') + } + + expect( + queryClient.getQueryCache().find({ queryKey: [key, 'defaultInput'] })! + .observers.length, + ).toBe(0) + + expect( + queryClient.getQueryCache().find({ queryKey: [key, 'someInput'] })! + .observers.length, + ).toBe(2) + }) }) }) diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index 9f0cbce408..a19047be86 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -6,6 +6,7 @@ import { dehydrate, hydrate, skipToken } from '@tanstack/query-core' import { QueryCache, keepPreviousData, useQuery } from '..' import { Blink, + arrayPick, createQueryClient, mockOnlineManagerIsOnline, mockVisibilityState, @@ -2485,38 +2486,6 @@ describe('useQuery', () => { expect(queryCache.find({ queryKey: key })!.options.retryDelay).toBe(20) }) - it('should batch re-renders', async () => { - const key = queryKey() - - let renders = 0 - - const queryFn = async () => { - await sleep(15) - return 'data' - } - - function Page() { - const query1 = useQuery({ queryKey: key, queryFn }) - const query2 = useQuery({ queryKey: key, queryFn }) - renders++ - - return ( -
- {query1.data} {query2.data} -
- ) - } - - const rendered = renderWithClient(queryClient, ) - - await waitFor(() => { - rendered.getByText('data data') - }) - - // Should be 2 instead of 3 - expect(renders).toBe(2) - }) - it('should render latest data even if react has discarded certain renders', async () => { const key = queryKey() @@ -4803,6 +4772,7 @@ describe('useQuery', () => { return count }, staleTime: Infinity, + notifyOnChangeProps: 'all', }) states.push(state) @@ -4829,34 +4799,53 @@ describe('useQuery', () => { expect(count).toBe(2) - expect(states[0]).toMatchObject({ - data: undefined, - isPending: true, - isFetching: true, - isSuccess: false, - isStale: true, - }) - expect(states[1]).toMatchObject({ - data: 1, - isPending: false, - isFetching: false, - isSuccess: true, - isStale: false, - }) - expect(states[2]).toMatchObject({ - data: undefined, - isPending: true, - isFetching: true, - isSuccess: false, - isStale: true, - }) - expect(states[3]).toMatchObject({ - data: 2, - isPending: false, - isFetching: false, - isSuccess: true, - isStale: false, - }) + expect( + arrayPick(states, [ + 'data', + 'isStale', + 'isFetching', + 'isPending', + 'isSuccess', + ]), + ).toMatchInlineSnapshot(` + [ + { + "data": undefined, + "isFetching": true, + "isPending": true, + "isStale": true, + "isSuccess": false, + }, + { + "data": 1, + "isFetching": false, + "isPending": false, + "isStale": false, + "isSuccess": true, + }, + { + "data": undefined, + "isFetching": true, + "isPending": true, + "isStale": true, + "isSuccess": false, + }, + { + "data": undefined, + "isFetching": true, + "isPending": true, + "isStale": true, + "isSuccess": false, + }, + { + "data": 2, + "isFetching": false, + "isPending": false, + "isStale": false, + "isSuccess": true, + }, + ] + `) }) it('should update query state and not refetch when resetting a disabled query with resetQueries', async () => { diff --git a/packages/react-query/src/__tests__/utils.tsx b/packages/react-query/src/__tests__/utils.tsx index 7e25177768..dd2e7c13f8 100644 --- a/packages/react-query/src/__tests__/utils.tsx +++ b/packages/react-query/src/__tests__/utils.tsx @@ -94,3 +94,23 @@ export function setIsServer(isServer: boolean) { } export const doNotExecute = (_func: () => void) => true + +function pick( + obj: T, + keys: Array, +): Pick { + return keys.reduce( + (acc, key) => { + acc[key] = obj[key] + return acc + }, + {} as Pick, + ) +} + +export function arrayPick( + list: Array, + keys: Array, +): Array> { + return list.map((item) => pick(item, keys)) +} diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index bcbf700ef7..0438b362a6 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -82,33 +82,44 @@ export function useBaseQuery< ), ) + const [_, setForceUpdate] = React.useState({}) + const result = observer.getOptimisticResult(defaultedOptions) - React.useSyncExternalStore( - React.useCallback( - (onStoreChange) => { - const unsubscribe = isRestoring - ? noop - : observer.subscribe(notifyManager.batchCalls(onStoreChange)) - - // Update result to make sure we did not miss any query updates - // between creating the observer and subscribing to it. - observer.updateResult() - - return unsubscribe - }, - [observer, isRestoring], - ), - () => observer.getCurrentResult(), - () => observer.getCurrentResult(), - ) + React.useEffect(() => { + if (isRestoring) { + return + } + + const unsubscribe = observer.subscribe( + notifyManager.batchCalls(() => { + setForceUpdate({}) + }), + ) + + // Update result to make sure we did not miss any query updates + // between creating the observer and subscribing to it. + observer.updateResult() + + return unsubscribe + }, [observer, isRestoring]) React.useEffect(() => { + if (defaultedOptions.experimental_prefetchInRender) { + return + } // Do not notify on updates because of changes in the options because // these changes should already be reflected in the optimistic result. observer.setOptions(defaultedOptions, { listeners: false }) }, [defaultedOptions, observer]) + // For prefetchInRender, we need to set the options within the render + if (defaultedOptions.experimental_prefetchInRender) { + // Do not notify on updates because of changes in the options because + // these changes should already be reflected in the optimistic result. + observer.setOptions(defaultedOptions, { listeners: false }) + } + // Handle suspense if (shouldSuspend(defaultedOptions, result)) { throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary) diff --git a/packages/react-query/vite.config.ts b/packages/react-query/vite.config.ts index fba5f8d044..9edede43c7 100644 --- a/packages/react-query/vite.config.ts +++ b/packages/react-query/vite.config.ts @@ -7,7 +7,6 @@ export default defineConfig({ test: { name: packageJson.name, dir: './src', - watch: false, environment: 'jsdom', setupFiles: ['test-setup.ts'], coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0941c8880..20793c3f51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1313,6 +1313,31 @@ importers: specifier: ^5.3.5 version: 5.4.11(@types/node@22.9.3)(less@4.2.1)(lightningcss@1.27.0)(sass@1.81.0)(terser@5.31.6) + examples/react/transition: + dependencies: + '@tanstack/react-query': + specifier: ^5.62.8 + version: link:../../../packages/react-query + '@tanstack/react-query-devtools': + specifier: ^5.62.8 + version: link:../../../packages/react-query-devtools + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + devDependencies: + '@vitejs/plugin-react': + specifier: ^4.3.3 + version: 4.3.3(vite@5.4.11(@types/node@22.9.3)(less@4.2.1)(lightningcss@1.27.0)(sass@1.81.0)(terser@5.31.6)) + typescript: + specifier: 5.7.2 + version: 5.7.2 + vite: + specifier: ^5.3.5 + version: 5.4.11(@types/node@22.9.3)(less@4.2.1)(lightningcss@1.27.0)(sass@1.81.0)(terser@5.31.6) + examples/solid/astro: dependencies: '@astrojs/check':