Skip to content

Commit

Permalink
feat: add support for React.use() (#7988)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
6 people authored Oct 1, 2024
1 parent 55a6155 commit 18953be
Show file tree
Hide file tree
Showing 21 changed files with 1,299 additions and 39 deletions.
50 changes: 50 additions & 0 deletions docs/framework/react/guides/suspense.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ React Query can also be used with React's Suspense for Data Fetching API's. For
- [useSuspenseQuery](../../reference/useSuspenseQuery)
- [useSuspenseInfiniteQuery](../../reference/useSuspenseInfiniteQuery)
- [useSuspenseQueries](../../reference/useSuspenseQueries)
- Additionally, you can use the `useQuery().promise` and `React.use()` (Experimental)

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.

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

For more information, check out the [NextJs Suspense Streaming Example](../../examples/nextjs-suspense-streaming) and the [Advanced Rendering & Hydration](../advanced-ssr) guide.

## Using `useQuery().promise` and `React.use()` (Experimental)

> To enable this feature, you need to set the `experimental_prefetchInRender` option to `true` when creating your `QueryClient`
**Example code:**

```tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
experimental_prefetchInRender: true,
},
},
})
```

**Usage:**

```tsx
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchTodos, type Todo } from './api'

function TodoList({ query }: { query: UseQueryResult<Todo[]> }) {
const data = React.use(query.promise)

return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}

export function App() {
const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

return (
<>
<h1>Todos</h1>
<React.Suspense fallback={<div>Loading...</div>}>
<TodoList query={query} />
</React.Suspense>
</>
)
}
```
5 changes: 5 additions & 0 deletions docs/framework/react/reference/queryOptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ You can generally pass everything to `queryOptions` that you can also pass to [`
- `queryKey: QueryKey`
- **Required**
- The query key to generate options for.
- `experimental_prefetchInRender?: boolean`
- Optional
- Defaults to `false`
- When set to `true`, queries will be prefetched during render, which can be useful for certain optimization scenarios
- Needs to be turned on for the experimental `useQuery().promise` functionality
5 changes: 5 additions & 0 deletions docs/framework/react/reference/useInfiniteQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
promise,
...result
} = useInfiniteQuery({
queryKey,
Expand Down Expand Up @@ -85,5 +86,9 @@ The returned properties for `useInfiniteQuery` are identical to the [`useQuery`
- Is the same as `isFetching && !isPending && !isFetchingNextPage && !isFetchingPreviousPage`
- `isRefetchError: boolean`
- Will be `true` if the query failed while refetching a page.
- `promise: Promise<TData>`
- A stable promise that resolves to the query result.
- This can be used with `React.use()` to fetch data
- Requires the `experimental_prefetchInRender` feature flag to be enabled on the `QueryClient`.

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`.
4 changes: 4 additions & 0 deletions docs/framework/react/reference/useQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const {
isRefetching,
isStale,
isSuccess,
promise,
refetch,
status,
} = useQuery(
Expand Down Expand Up @@ -244,3 +245,6 @@ const {
- Defaults to `true`
- Per default, a currently running request will be cancelled before a new request is made
- When set to `false`, no refetch will be made if there is already a request running.
- `promise: Promise<TData>`
- A stable promise that will be resolved with the data of the query.
- Requires the `experimental_prefetchInRender` feature flag to be enabled on the `QueryClient`.
102 changes: 101 additions & 1 deletion packages/query-core/src/__tests__/queryObserver.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ describe('queryObserver', () => {
let queryClient: QueryClient

beforeEach(() => {
queryClient = createQueryClient()
queryClient = createQueryClient({
defaultOptions: {
queries: {
experimental_prefetchInRender: true,
},
},
})
queryClient.mount()
})

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

unsubscribe()
})

test('should return a promise that resolves when data is present', async () => {
const results: Array<QueryObserverResult> = []
const key = queryKey()
let count = 0
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: () => {
if (++count > 9) {
return Promise.resolve('data')
}
throw new Error('rejected')
},
retry: 10,
retryDelay: 0,
})
const unsubscribe = observer.subscribe(() => {
results.push(observer.getCurrentResult())
})

await waitFor(() => {
expect(results.at(-1)?.data).toBe('data')
})

const numberOfUniquePromises = new Set(
results.map((result) => result.promise),
).size
expect(numberOfUniquePromises).toBe(1)

unsubscribe()
})

test('should return a new promise after recovering from an error', async () => {
const results: Array<QueryObserverResult> = []
const key = queryKey()

let succeeds = false
let idx = 0
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: () => {
if (succeeds) {
return Promise.resolve('data')
}
throw new Error(`rejected #${++idx}`)
},
retry: 5,
retryDelay: 0,
})
const unsubscribe = observer.subscribe(() => {
results.push(observer.getCurrentResult())
})

await waitFor(() => {
expect(results.at(-1)?.status).toBe('error')
})

expect(
results.every((result) => result.promise === results[0]!.promise),
).toBe(true)

{
// fail again
const lengthBefore = results.length
observer.refetch()
await waitFor(() => {
expect(results.length).toBeGreaterThan(lengthBefore)
expect(results.at(-1)?.status).toBe('error')
})

const numberOfUniquePromises = new Set(
results.map((result) => result.promise),
).size

expect(numberOfUniquePromises).toBe(2)
}
{
// succeed
succeeds = true
observer.refetch()

await waitFor(() => {
results.at(-1)?.status === 'success'
})

const numberOfUniquePromises = new Set(
results.map((result) => result.promise),
).size

expect(numberOfUniquePromises).toBe(3)
}

unsubscribe()
})
})
67 changes: 63 additions & 4 deletions packages/query-core/src/queryObserver.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { focusManager } from './focusManager'
import { notifyManager } from './notifyManager'
import { fetchState } from './query'
import { Subscribable } from './subscribable'
import { pendingThenable } from './thenable'
import {
isServer,
isValidTimeout,
Expand All @@ -8,12 +13,9 @@ import {
shallowEqualObjects,
timeUntilStale,
} from './utils'
import { notifyManager } from './notifyManager'
import { focusManager } from './focusManager'
import { Subscribable } from './subscribable'
import { fetchState } from './query'
import type { FetchOptions, Query, QueryState } from './query'
import type { QueryClient } from './queryClient'
import type { PendingThenable, Thenable } from './thenable'
import type {
DefaultError,
DefaultedQueryObserverOptions,
Expand Down Expand Up @@ -57,6 +59,7 @@ export class QueryObserver<
TQueryData,
TQueryKey
>
#currentThenable: Thenable<TData>
#selectError: TError | null
#selectFn?: (data: TQueryData) => TData
#selectResult?: TData
Expand All @@ -82,6 +85,13 @@ export class QueryObserver<

this.#client = client
this.#selectError = null
this.#currentThenable = pendingThenable()
if (!this.options.experimental_prefetchInRender) {
this.#currentThenable.reject(
new Error('experimental_prefetchInRender feature flag is not enabled'),
)
}

this.bindMethods()
this.setOptions(options)
}
Expand Down Expand Up @@ -582,6 +592,7 @@ export class QueryObserver<
isRefetchError: isError && hasData,
isStale: isStale(query, options),
refetch: this.refetch,
promise: this.#currentThenable,
}

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

const nextResult = this.createResult(this.#currentQuery, this.options)

this.#currentResultState = this.#currentQuery.state
this.#currentResultOptions = this.options

Expand All @@ -605,6 +617,52 @@ export class QueryObserver<
return
}

if (this.options.experimental_prefetchInRender) {
const finalizeThenableIfPossible = (thenable: PendingThenable<TData>) => {
if (nextResult.status === 'error') {
thenable.reject(nextResult.error)
} else if (nextResult.data !== undefined) {
thenable.resolve(nextResult.data)
}
}

/**
* Create a new thenable and result promise when the results have changed
*/
const recreateThenable = () => {
const pending =
(this.#currentThenable =
nextResult.promise =
pendingThenable())

finalizeThenableIfPossible(pending)
}

const prevThenable = this.#currentThenable
switch (prevThenable.status) {
case 'pending':
// Finalize the previous thenable if it was pending
finalizeThenableIfPossible(prevThenable)
break
case 'fulfilled':
if (
nextResult.status === 'error' ||
nextResult.data !== prevThenable.value
) {
recreateThenable()
}
break
case 'rejected':
if (
nextResult.status !== 'error' ||
nextResult.error !== prevThenable.reason
) {
recreateThenable()
}
break
}
}

this.#currentResult = nextResult

// Determine which callbacks to trigger
Expand Down Expand Up @@ -639,6 +697,7 @@ export class QueryObserver<
return Object.keys(this.#currentResult).some((key) => {
const typedKey = key as keyof QueryObserverResult
const changed = this.#currentResult[typedKey] !== prevResult[typedKey]

return changed && includedProps.has(typedKey)
})
}
Expand Down
Loading

0 comments on commit 18953be

Please sign in to comment.