Skip to content

Commit 18953be

Browse files
KATTMini-ghosttannerlinsleyDamianOsipiukautofix-ci[bot]
authored
feat: add support for React.use() (#7988)
* let’s do it again * fix test group * maybe * mkay * cool * rm console.logs * mkay * mkay * fix(vue-query): invalidate queries immediately after calling `invalidateQueries` (#7930) * fix(vue-query): invalidate queries immediately after call `invalidateQueries` * chore: recovery code comments * release: v5.53.2 * docs(vue-query): update SSR guide for nuxt2 (#8001) * docs: update SSR guide for nuxt2 * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * thenable * mkay * Update packages/react-query/src/__tests__/useQuery.test.tsx * mkay * mkay * faster and more consistent * mkay * mkay * mkay * mkay * mkay * fix unhandled rejections * more * more * mkay * fix more * fixy * cool * Update packages/react-query/package.json * fix: track data property if `promise` is tracked if users use the `promise` returned from useQuery, they are actually interested in the `data` it unwraps to. Since the promise doesn't change when data resolves, we would likely miss a re-render * Revert "fix: track data property if `promise` is tracked" This reverts commit d1184ba. * add test case that @TkDodo was concerned about * tweak * mkay * add `useInfiniteQuery()` test * consistent testing * better test * rm comment * test resetting errror boundary * better test * cool * cool * more test * mv cleanup * mkay * some more things * add fixme * fix types * wat * fixes * revert * fix * colocating doesn’t workkk * mkay * mkay * might work * more test * cool * i don’t know hwat i’m doing * mocky * lint * space * rm log * setIsServer * mkay * ffs * remove unnecessary stufffff * tweak more * just naming and comments * tweak * fix: use fetchOptimistic util instead of observer.fetchOptimistic * refactor: make sure to only trigger fetching during render if we really have no cache entry yet * fix: move the `isNewCacheEntry` check before observer creation * chore: avoid rect key warnings * fix: add an `updateResult` for all observers to finalize currentThenable * chore: logs during suspense errors * fix: empty catch * feature flag * add comment * simplify * omit from suspense * feat flag * more tests * test: scope experimental_promise to useQuery().promise tests * refactor: rename to experimental_prefetchInRender * test: more tests * test: more cancelation * fix cancellation * make it work * tweak comment * Update packages/react-query/src/useBaseQuery.ts * simplify code a bit * Update packages/query-core/src/queryObserver.ts * refactor: move experimental_prefetchInRender check until after the early bail-out * fix: when cancelled, the promise should stay pending * test: disabled case * chore: no idea what's going on * refactor: delete unnecessary check * revert refactor i did for cancellation when we wanted it to `throw` * add docs * align * tweak * Update docs/reference/QueryClient.md * Update docs/framework/react/reference/queryOptions.md --------- Co-authored-by: Alex Liu <[email protected]> Co-authored-by: Tanner Linsley <[email protected]> Co-authored-by: Damian Osipiuk <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Dominik Dorfmeister <[email protected]>
1 parent 55a6155 commit 18953be

21 files changed

+1299
-39
lines changed

docs/framework/react/guides/suspense.md

+50
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ React Query can also be used with React's Suspense for Data Fetching API's. For
88
- [useSuspenseQuery](../../reference/useSuspenseQuery)
99
- [useSuspenseInfiniteQuery](../../reference/useSuspenseInfiniteQuery)
1010
- [useSuspenseQueries](../../reference/useSuspenseQueries)
11+
- Additionally, you can use the `useQuery().promise` and `React.use()` (Experimental)
1112

1213
When using suspense mode, `status` states and `error` objects are not needed and are then replaced by usage of the `React.Suspense` component (including the use of the `fallback` prop and React error boundaries for catching errors). Please read the [Resetting Error Boundaries](#resetting-error-boundaries) and look at the [Suspense Example](https://stackblitz.com/github/TanStack/query/tree/main/examples/react/suspense) for more information on how to set up suspense mode.
1314

@@ -172,3 +173,52 @@ export function Providers(props: { children: React.ReactNode }) {
172173
```
173174

174175
For more information, check out the [NextJs Suspense Streaming Example](../../examples/nextjs-suspense-streaming) and the [Advanced Rendering & Hydration](../advanced-ssr) guide.
176+
177+
## Using `useQuery().promise` and `React.use()` (Experimental)
178+
179+
> To enable this feature, you need to set the `experimental_prefetchInRender` option to `true` when creating your `QueryClient`
180+
181+
**Example code:**
182+
183+
```tsx
184+
const queryClient = new QueryClient({
185+
defaultOptions: {
186+
queries: {
187+
experimental_prefetchInRender: true,
188+
},
189+
},
190+
})
191+
```
192+
193+
**Usage:**
194+
195+
```tsx
196+
import React from 'react'
197+
import { useQuery } from '@tanstack/react-query'
198+
import { fetchTodos, type Todo } from './api'
199+
200+
function TodoList({ query }: { query: UseQueryResult<Todo[]> }) {
201+
const data = React.use(query.promise)
202+
203+
return (
204+
<ul>
205+
{data.map((todo) => (
206+
<li key={todo.id}>{todo.title}</li>
207+
))}
208+
</ul>
209+
)
210+
}
211+
212+
export function App() {
213+
const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
214+
215+
return (
216+
<>
217+
<h1>Todos</h1>
218+
<React.Suspense fallback={<div>Loading...</div>}>
219+
<TodoList query={query} />
220+
</React.Suspense>
221+
</>
222+
)
223+
}
224+
```

docs/framework/react/reference/queryOptions.md

+5
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,8 @@ You can generally pass everything to `queryOptions` that you can also pass to [`
1717
- `queryKey: QueryKey`
1818
- **Required**
1919
- The query key to generate options for.
20+
- `experimental_prefetchInRender?: boolean`
21+
- Optional
22+
- Defaults to `false`
23+
- When set to `true`, queries will be prefetched during render, which can be useful for certain optimization scenarios
24+
- Needs to be turned on for the experimental `useQuery().promise` functionality

docs/framework/react/reference/useInfiniteQuery.md

+5
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
hasPreviousPage,
1212
isFetchingNextPage,
1313
isFetchingPreviousPage,
14+
promise,
1415
...result
1516
} = useInfiniteQuery({
1617
queryKey,
@@ -85,5 +86,9 @@ The returned properties for `useInfiniteQuery` are identical to the [`useQuery`
8586
- Is the same as `isFetching && !isPending && !isFetchingNextPage && !isFetchingPreviousPage`
8687
- `isRefetchError: boolean`
8788
- Will be `true` if the query failed while refetching a page.
89+
- `promise: Promise<TData>`
90+
- A stable promise that resolves to the query result.
91+
- This can be used with `React.use()` to fetch data
92+
- Requires the `experimental_prefetchInRender` feature flag to be enabled on the `QueryClient`.
8893

8994
Keep in mind that imperative fetch calls, such as `fetchNextPage`, may interfere with the default refetch behaviour, resulting in outdated data. Make sure to call these functions only in response to user actions, or add conditions like `hasNextPage && !isFetching`.

docs/framework/react/reference/useQuery.md

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const {
2626
isRefetching,
2727
isStale,
2828
isSuccess,
29+
promise,
2930
refetch,
3031
status,
3132
} = useQuery(
@@ -244,3 +245,6 @@ const {
244245
- Defaults to `true`
245246
- Per default, a currently running request will be cancelled before a new request is made
246247
- When set to `false`, no refetch will be made if there is already a request running.
248+
- `promise: Promise<TData>`
249+
- A stable promise that will be resolved with the data of the query.
250+
- Requires the `experimental_prefetchInRender` feature flag to be enabled on the `QueryClient`.

packages/query-core/src/__tests__/queryObserver.test.tsx

+101-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ describe('queryObserver', () => {
1616
let queryClient: QueryClient
1717

1818
beforeEach(() => {
19-
queryClient = createQueryClient()
19+
queryClient = createQueryClient({
20+
defaultOptions: {
21+
queries: {
22+
experimental_prefetchInRender: true,
23+
},
24+
},
25+
})
2026
queryClient.mount()
2127
})
2228

@@ -1133,4 +1139,98 @@ describe('queryObserver', () => {
11331139

11341140
unsubscribe()
11351141
})
1142+
1143+
test('should return a promise that resolves when data is present', async () => {
1144+
const results: Array<QueryObserverResult> = []
1145+
const key = queryKey()
1146+
let count = 0
1147+
const observer = new QueryObserver(queryClient, {
1148+
queryKey: key,
1149+
queryFn: () => {
1150+
if (++count > 9) {
1151+
return Promise.resolve('data')
1152+
}
1153+
throw new Error('rejected')
1154+
},
1155+
retry: 10,
1156+
retryDelay: 0,
1157+
})
1158+
const unsubscribe = observer.subscribe(() => {
1159+
results.push(observer.getCurrentResult())
1160+
})
1161+
1162+
await waitFor(() => {
1163+
expect(results.at(-1)?.data).toBe('data')
1164+
})
1165+
1166+
const numberOfUniquePromises = new Set(
1167+
results.map((result) => result.promise),
1168+
).size
1169+
expect(numberOfUniquePromises).toBe(1)
1170+
1171+
unsubscribe()
1172+
})
1173+
1174+
test('should return a new promise after recovering from an error', async () => {
1175+
const results: Array<QueryObserverResult> = []
1176+
const key = queryKey()
1177+
1178+
let succeeds = false
1179+
let idx = 0
1180+
const observer = new QueryObserver(queryClient, {
1181+
queryKey: key,
1182+
queryFn: () => {
1183+
if (succeeds) {
1184+
return Promise.resolve('data')
1185+
}
1186+
throw new Error(`rejected #${++idx}`)
1187+
},
1188+
retry: 5,
1189+
retryDelay: 0,
1190+
})
1191+
const unsubscribe = observer.subscribe(() => {
1192+
results.push(observer.getCurrentResult())
1193+
})
1194+
1195+
await waitFor(() => {
1196+
expect(results.at(-1)?.status).toBe('error')
1197+
})
1198+
1199+
expect(
1200+
results.every((result) => result.promise === results[0]!.promise),
1201+
).toBe(true)
1202+
1203+
{
1204+
// fail again
1205+
const lengthBefore = results.length
1206+
observer.refetch()
1207+
await waitFor(() => {
1208+
expect(results.length).toBeGreaterThan(lengthBefore)
1209+
expect(results.at(-1)?.status).toBe('error')
1210+
})
1211+
1212+
const numberOfUniquePromises = new Set(
1213+
results.map((result) => result.promise),
1214+
).size
1215+
1216+
expect(numberOfUniquePromises).toBe(2)
1217+
}
1218+
{
1219+
// succeed
1220+
succeeds = true
1221+
observer.refetch()
1222+
1223+
await waitFor(() => {
1224+
results.at(-1)?.status === 'success'
1225+
})
1226+
1227+
const numberOfUniquePromises = new Set(
1228+
results.map((result) => result.promise),
1229+
).size
1230+
1231+
expect(numberOfUniquePromises).toBe(3)
1232+
}
1233+
1234+
unsubscribe()
1235+
})
11361236
})

packages/query-core/src/queryObserver.ts

+63-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import { focusManager } from './focusManager'
2+
import { notifyManager } from './notifyManager'
3+
import { fetchState } from './query'
4+
import { Subscribable } from './subscribable'
5+
import { pendingThenable } from './thenable'
16
import {
27
isServer,
38
isValidTimeout,
@@ -8,12 +13,9 @@ import {
813
shallowEqualObjects,
914
timeUntilStale,
1015
} from './utils'
11-
import { notifyManager } from './notifyManager'
12-
import { focusManager } from './focusManager'
13-
import { Subscribable } from './subscribable'
14-
import { fetchState } from './query'
1516
import type { FetchOptions, Query, QueryState } from './query'
1617
import type { QueryClient } from './queryClient'
18+
import type { PendingThenable, Thenable } from './thenable'
1719
import type {
1820
DefaultError,
1921
DefaultedQueryObserverOptions,
@@ -57,6 +59,7 @@ export class QueryObserver<
5759
TQueryData,
5860
TQueryKey
5961
>
62+
#currentThenable: Thenable<TData>
6063
#selectError: TError | null
6164
#selectFn?: (data: TQueryData) => TData
6265
#selectResult?: TData
@@ -82,6 +85,13 @@ export class QueryObserver<
8285

8386
this.#client = client
8487
this.#selectError = null
88+
this.#currentThenable = pendingThenable()
89+
if (!this.options.experimental_prefetchInRender) {
90+
this.#currentThenable.reject(
91+
new Error('experimental_prefetchInRender feature flag is not enabled'),
92+
)
93+
}
94+
8595
this.bindMethods()
8696
this.setOptions(options)
8797
}
@@ -582,6 +592,7 @@ export class QueryObserver<
582592
isRefetchError: isError && hasData,
583593
isStale: isStale(query, options),
584594
refetch: this.refetch,
595+
promise: this.#currentThenable,
585596
}
586597

587598
return result as QueryObserverResult<TData, TError>
@@ -593,6 +604,7 @@ export class QueryObserver<
593604
| undefined
594605

595606
const nextResult = this.createResult(this.#currentQuery, this.options)
607+
596608
this.#currentResultState = this.#currentQuery.state
597609
this.#currentResultOptions = this.options
598610

@@ -605,6 +617,52 @@ export class QueryObserver<
605617
return
606618
}
607619

620+
if (this.options.experimental_prefetchInRender) {
621+
const finalizeThenableIfPossible = (thenable: PendingThenable<TData>) => {
622+
if (nextResult.status === 'error') {
623+
thenable.reject(nextResult.error)
624+
} else if (nextResult.data !== undefined) {
625+
thenable.resolve(nextResult.data)
626+
}
627+
}
628+
629+
/**
630+
* Create a new thenable and result promise when the results have changed
631+
*/
632+
const recreateThenable = () => {
633+
const pending =
634+
(this.#currentThenable =
635+
nextResult.promise =
636+
pendingThenable())
637+
638+
finalizeThenableIfPossible(pending)
639+
}
640+
641+
const prevThenable = this.#currentThenable
642+
switch (prevThenable.status) {
643+
case 'pending':
644+
// Finalize the previous thenable if it was pending
645+
finalizeThenableIfPossible(prevThenable)
646+
break
647+
case 'fulfilled':
648+
if (
649+
nextResult.status === 'error' ||
650+
nextResult.data !== prevThenable.value
651+
) {
652+
recreateThenable()
653+
}
654+
break
655+
case 'rejected':
656+
if (
657+
nextResult.status !== 'error' ||
658+
nextResult.error !== prevThenable.reason
659+
) {
660+
recreateThenable()
661+
}
662+
break
663+
}
664+
}
665+
608666
this.#currentResult = nextResult
609667

610668
// Determine which callbacks to trigger
@@ -639,6 +697,7 @@ export class QueryObserver<
639697
return Object.keys(this.#currentResult).some((key) => {
640698
const typedKey = key as keyof QueryObserverResult
641699
const changed = this.#currentResult[typedKey] !== prevResult[typedKey]
700+
642701
return changed && includedProps.has(typedKey)
643702
})
644703
}

0 commit comments

Comments
 (0)