-
-
Notifications
You must be signed in to change notification settings - Fork 3.1k
fix(react-query): move away of uSES #8434
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7d3fccc
bc36411
63c548c
3ed5349
784cb83
fb21469
a7ba6bc
b6917d2
71ccdcf
0faec03
16e9c59
510bcbe
76fd305
f035539
ec4d91e
bd25f47
3002562
61d9be3
46d145c
201169a
3685c95
a1890ed
123d6e4
cc5d27f
f5c1e5d
ab24be3
27d348c
e2dca98
c0d3680
77f4616
a1e2dd8
50dfe93
3f6748a
6e4a559
2649b87
d878172
4b9c5d8
19f7230
38b8864
84dffdc
a728d6b
58f0f77
984fa4b
cfdf512
ed313f7
dcf16f2
60f6736
5a97880
aba6e7f
b5d30af
e6a7d00
847e7da
cb449e0
4a5296c
c0d341e
02d9727
442c4d5
f93377b
76bfacf
efed3ff
5a1358e
30e819b
83dddf0
7e7d55c
7df5d81
9096707
9797e93
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
{ | ||
"editor.defaultFormatter": "esbenp.prettier-vscode", | ||
"editor.formatOnSave": true | ||
} | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"extends": ["plugin:react/jsx-runtime", "plugin:react-hooks/recommended"] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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* |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
# Example | ||
|
||
To run this example: | ||
|
||
- `pnpm install` | ||
- `pnpm dev` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
<!doctype html> | ||
<html lang="en"> | ||
<head> | ||
<meta charset="utf-8" /> | ||
<link rel="shortcut icon" type="image/svg+xml" href="/emblem-light.svg" /> | ||
<meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
<meta name="theme-color" content="#000000" /> | ||
|
||
<title>TanStack Query React Suspense Example App</title> | ||
</head> | ||
<body> | ||
<noscript>You need to enable JavaScript to run this app.</noscript> | ||
<div id="root"></div> | ||
<script type="module" src="/src/index.tsx"></script> | ||
</body> | ||
</html> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<div> | ||
{data} {isFetching ? 'fetching' : null} | ||
</div> | ||
) | ||
} | ||
|
||
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 ( | ||
<div> | ||
{data} {isFetching ? 'fetching' : null} | ||
</div> | ||
) | ||
} | ||
|
||
const SuspenseBoundary = () => { | ||
const [state, setState] = useState(-1) | ||
const [isPending, startTransition] = useTransition() | ||
|
||
return ( | ||
<div> | ||
<h1>Change state with transition</h1> | ||
<div> | ||
<button | ||
onClick={() => | ||
startTransition(() => { | ||
setState((s) => s - 1) | ||
}) | ||
} | ||
> | ||
Decrease | ||
</button> | ||
</div> | ||
<h2>State:</h2> | ||
<ul> | ||
<li>last state value: {state}</li> | ||
<li> | ||
transition state: {isPending ? <strong>pending</strong> : 'idle'} | ||
</li> | ||
</ul> | ||
<h2>2. 1 Suspense + startTransition</h2> | ||
<Suspense fallback="fallback 1"> | ||
<Example1 value={state}></Example1> | ||
</Suspense> | ||
<h2>2.2 Suspense + startTransition</h2> | ||
<Suspense fallback="fallback 2"> | ||
<Example2 value={state}></Example2> | ||
</Suspense> | ||
</div> | ||
) | ||
} | ||
|
||
const queryClient = new QueryClient({ | ||
defaultOptions: { | ||
queries: { | ||
experimental_prefetchInRender: true, | ||
staleTime: 10 * 1000, | ||
}, | ||
}, | ||
}) | ||
|
||
const App = () => { | ||
return ( | ||
<div> | ||
<QueryClientProvider client={queryClient}> | ||
<SuspenseBoundary /> | ||
</QueryClientProvider> | ||
</div> | ||
) | ||
} | ||
|
||
const rootElement = document.getElementById('root') as HTMLElement | ||
ReactDOM.createRoot(rootElement).render(<App />) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import { defineConfig } from 'vite' | ||
import react from '@vitejs/plugin-react' | ||
|
||
export default defineConfig({ | ||
plugins: [react()], | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
Comment on lines
+15
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. followed what Rick suggested here https://discord.com/channels/514829729862516747/1251229763838677052/1318328930658418718 |
||
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 ( | ||
<div> | ||
<button onClick={() => startTransition(() => setCount((c) => c + 1))}> | ||
increment | ||
</button> | ||
<div>data: {data}</div> | ||
{isPending && <span>pending...</span>} | ||
</div> | ||
) | ||
} | ||
// Initial render should show fallback | ||
await act(async () => { | ||
render( | ||
<QueryClientProvider client={queryClient}> | ||
<React.Suspense fallback={<Loading />}> | ||
<Page /> | ||
</React.Suspense> | ||
</QueryClientProvider>, | ||
) | ||
}) | ||
|
||
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 <div>A data: {data}</div> | ||
} | ||
|
||
function ComponentALoading() { | ||
return <div>A loading...</div> | ||
} | ||
|
||
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 <div>B data: {data}</div> | ||
} | ||
|
||
function ComponentBLoading() { | ||
return <div>B loading...</div> | ||
} | ||
|
||
function Parent() { | ||
const [count, setCount] = React.useState(0) | ||
const [isPending, startTransition] = React.useTransition() | ||
return ( | ||
<div> | ||
<button onClick={() => startTransition(() => setCount((c) => c + 1))}> | ||
increment | ||
</button> | ||
<React.Suspense fallback={<ComponentALoading />}> | ||
<ComponentA parentId={count} /> | ||
</React.Suspense> | ||
<React.Suspense fallback={<ComponentBLoading />}> | ||
<ComponentB parentId={count} /> | ||
</React.Suspense> | ||
{isPending && <span>pending...</span>} | ||
</div> | ||
) | ||
} | ||
|
||
// Initial render should show fallback | ||
await act(async () => { | ||
render( | ||
<QueryClientProvider client={queryClient}> | ||
<Parent /> | ||
</QueryClientProvider>, | ||
) | ||
}) | ||
|
||
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<number, () => void> = {} | ||
|
||
const key = queryKey() | ||
|
||
function Component(props: { count: number }) { | ||
const { count } = props | ||
|
||
const query = useQuery({ | ||
queryKey: [key, count], | ||
queryFn: async () => { | ||
await new Promise<void>((resolve) => { | ||
resolversByCount[count] = resolve | ||
}) | ||
|
||
return 'test' + count | ||
}, | ||
staleTime: 1000, | ||
}) | ||
const data = React.use(query.promise) | ||
return <div>data: {data}</div> | ||
} | ||
|
||
function Page() { | ||
const [isPending, startTransition] = React.useTransition() | ||
const [count, setCount] = React.useState(0) | ||
|
||
return ( | ||
<div> | ||
<button onClick={() => startTransition(() => setCount((c) => c + 1))}> | ||
increment | ||
</button> | ||
<React.Suspense fallback="loading..."> | ||
<Component count={count} /> | ||
</React.Suspense> | ||
{isPending && 'pending...'} | ||
</div> | ||
) | ||
} | ||
// Initial render should show fallback | ||
await act(async () => { | ||
render( | ||
<QueryClientProvider client={queryClient}> | ||
<Page /> | ||
</QueryClientProvider>, | ||
) | ||
}) | ||
|
||
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') | ||
}) |
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test doesn't work anymore when we're force rendering I'm guessing we just have to trust the concurrency in React to do this in an optimal way? |
||
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 ( | ||
<div> | ||
{query1.data} {query2.data} | ||
</div> | ||
) | ||
} | ||
|
||
const rendered = renderWithClient(queryClient, <Page />) | ||
|
||
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 () => { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -82,33 +82,44 @@ | |
), | ||
) | ||
|
||
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 }) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Setting the options straight away for I think the issue I had was some race condition... I still have no reliable reproduction though |
||
} | ||
|
||
// Handle suspense | ||
if (shouldSuspend(defaultedOptions, result)) { | ||
throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Feel free to remove this, but it helps me as a contributor - I have a bunch of settings in trpc https://github.com/trpc/trpc/tree/next/.vscode