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/react-query/src/__tests__/regression-8384-transition.test.tsx b/packages/react-query/src/__tests__/regression-8384-transition.test.tsx new file mode 100644 index 0000000000..14a47804ed --- /dev/null +++ b/packages/react-query/src/__tests__/regression-8384-transition.test.tsx @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/require-await */ +import { act, render, screen } from '@testing-library/react' +import * as React from 'react' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { QueryClientProvider, useQuery } from '..' +import { QueryCache } from '../index' +import { createQueryClient, queryKey, sleep } from './utils' + +describe('react transitions', () => { + 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 }, + }) + }) + afterAll(() => { + queryClient.setDefaultOptions({ + queries: { experimental_prefetchInRender: false }, + }) + }) + + it('should keep values of old key around with startTransition', async () => { + const key = queryKey() + const resolveByCount: Record void> = {} + + 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 new Promise((resolve) => { + resolveByCount[count] = resolve + }) + return 'test' + count + }, + }) + + const data = React.use(query.promise) + + return ( +
+ + {isPending && pending...} +
data: {data}
+
+ ) + } + + // 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 () => { + resolveByCount[0]!() + }) + // HELP WANTED - get the below to fail as the repro does + 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 () => { + for (let i = 0; i < 100; i++) { + screen.getByRole('button', { name: 'increment' }).click() + } + }) + + // resolve all + for (const resolve of Object.values(resolveByCount)) { + await sleep(1) + await act(async () => { + resolve() + }) + } + + expect(screen.queryByText('loading...')).toBeNull() + expect(screen.queryByText('pending...')).toBeNull() + screen.getByText('data: test100') + }) +}) diff --git a/packages/react-query/vite.config.ts b/packages/react-query/vite.config.ts index fba5f8d044..515825d864 100644 --- a/packages/react-query/vite.config.ts +++ b/packages/react-query/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ watch: false, environment: 'jsdom', setupFiles: ['test-setup.ts'], - coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] }, + coverage: { enabled: false, provider: 'istanbul', include: ['src/**/*'] }, typecheck: { enabled: true }, restoreMocks: true, retry: process.env.CI ? 3 : 0, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef044253a2..3d2f90c826 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':