From 18953be6f61553f4ed3dd888945dfa1683aa6e40 Mon Sep 17 00:00:00 2001 From: Alex / KATT Date: Tue, 1 Oct 2024 11:39:37 -0400 Subject: [PATCH] feat: add support for `React.use()` (#7988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 d1184babd1ba570da09798cad0e8613b7c5b69c8. * 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 Co-authored-by: Tanner Linsley Co-authored-by: Damian Osipiuk Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Dominik Dorfmeister --- docs/framework/react/guides/suspense.md | 50 ++ .../framework/react/reference/queryOptions.md | 5 + .../react/reference/useInfiniteQuery.md | 5 + docs/framework/react/reference/useQuery.md | 4 + .../src/__tests__/queryObserver.test.tsx | 102 ++- packages/query-core/src/queryObserver.ts | 67 +- packages/query-core/src/retryer.ts | 18 +- packages/query-core/src/thenable.ts | 82 ++ packages/query-core/src/types.ts | 54 ++ packages/react-query/package.json | 2 +- .../src/__tests__/prefetch.test.tsx | 11 +- .../react-query/src/__tests__/ssr.test.tsx | 6 +- .../src/__tests__/suspense.test.tsx | 1 - .../src/__tests__/useInfiniteQuery.test.tsx | 78 +- .../src/__tests__/useQuery.test.tsx | 804 +++++++++++++++++- packages/react-query/src/types.ts | 7 +- packages/react-query/src/useBaseQuery.ts | 32 +- packages/react-query/vite.config.ts | 1 + .../__tests__/createInfiniteQuery.test.tsx | 2 + .../src/__tests__/createQuery.test.tsx | 5 + .../createInfiniteQuery.test.ts | 2 + 21 files changed, 1299 insertions(+), 39 deletions(-) create mode 100644 packages/query-core/src/thenable.ts diff --git a/docs/framework/react/guides/suspense.md b/docs/framework/react/guides/suspense.md index 8049078587..31fac80357 100644 --- a/docs/framework/react/guides/suspense.md +++ b/docs/framework/react/guides/suspense.md @@ -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. @@ -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 }) { + const data = React.use(query.promise) + + return ( +
    + {data.map((todo) => ( +
  • {todo.title}
  • + ))} +
+ ) +} + +export function App() { + const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) + + return ( + <> +

Todos

+ Loading...}> + + + + ) +} +``` diff --git a/docs/framework/react/reference/queryOptions.md b/docs/framework/react/reference/queryOptions.md index 4cb6808e2c..39643f5539 100644 --- a/docs/framework/react/reference/queryOptions.md +++ b/docs/framework/react/reference/queryOptions.md @@ -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 diff --git a/docs/framework/react/reference/useInfiniteQuery.md b/docs/framework/react/reference/useInfiniteQuery.md index e47990ec3c..3b84814096 100644 --- a/docs/framework/react/reference/useInfiniteQuery.md +++ b/docs/framework/react/reference/useInfiniteQuery.md @@ -11,6 +11,7 @@ const { hasPreviousPage, isFetchingNextPage, isFetchingPreviousPage, + promise, ...result } = useInfiniteQuery({ queryKey, @@ -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` + - 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`. diff --git a/docs/framework/react/reference/useQuery.md b/docs/framework/react/reference/useQuery.md index eeffde61d4..ee23978b1c 100644 --- a/docs/framework/react/reference/useQuery.md +++ b/docs/framework/react/reference/useQuery.md @@ -26,6 +26,7 @@ const { isRefetching, isStale, isSuccess, + promise, refetch, status, } = useQuery( @@ -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` + - 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`. diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index b5d64ab467..183407787e 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -16,7 +16,13 @@ describe('queryObserver', () => { let queryClient: QueryClient beforeEach(() => { - queryClient = createQueryClient() + queryClient = createQueryClient({ + defaultOptions: { + queries: { + experimental_prefetchInRender: true, + }, + }, + }) queryClient.mount() }) @@ -1133,4 +1139,98 @@ describe('queryObserver', () => { unsubscribe() }) + + test('should return a promise that resolves when data is present', async () => { + const results: Array = [] + 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 = [] + 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() + }) }) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 2971bc6d1a..37442ce42f 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -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, @@ -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, @@ -57,6 +59,7 @@ export class QueryObserver< TQueryData, TQueryKey > + #currentThenable: Thenable #selectError: TError | null #selectFn?: (data: TQueryData) => TData #selectResult?: TData @@ -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) } @@ -582,6 +592,7 @@ export class QueryObserver< isRefetchError: isError && hasData, isStale: isStale(query, options), refetch: this.refetch, + promise: this.#currentThenable, } return result as QueryObserverResult @@ -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 @@ -605,6 +617,52 @@ export class QueryObserver< return } + if (this.options.experimental_prefetchInRender) { + const finalizeThenableIfPossible = (thenable: PendingThenable) => { + 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 @@ -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) }) } diff --git a/packages/query-core/src/retryer.ts b/packages/query-core/src/retryer.ts index 5eaa793bcd..baa93aa5b4 100644 --- a/packages/query-core/src/retryer.ts +++ b/packages/query-core/src/retryer.ts @@ -1,5 +1,6 @@ import { focusManager } from './focusManager' import { onlineManager } from './onlineManager' +import { pendingThenable } from './thenable' import { isServer, sleep } from './utils' import type { CancelOptions, DefaultError, NetworkMode } from './types' @@ -75,13 +76,8 @@ export function createRetryer( let failureCount = 0 let isResolved = false let continueFn: ((value?: unknown) => void) | undefined - let promiseResolve: (data: TData) => void - let promiseReject: (error: TError) => void - const promise = new Promise((outerResolve, outerReject) => { - promiseResolve = outerResolve - promiseReject = outerReject - }) + const thenable = pendingThenable() const cancel = (cancelOptions?: CancelOptions): void => { if (!isResolved) { @@ -110,7 +106,7 @@ export function createRetryer( isResolved = true config.onSuccess?.(value) continueFn?.() - promiseResolve(value) + thenable.resolve(value) } } @@ -119,7 +115,7 @@ export function createRetryer( isResolved = true config.onError?.(value) continueFn?.() - promiseReject(value) + thenable.reject(value) } } @@ -207,11 +203,11 @@ export function createRetryer( } return { - promise, + promise: thenable, cancel, continue: () => { continueFn?.() - return promise + return thenable }, cancelRetry, continueRetry, @@ -223,7 +219,7 @@ export function createRetryer( } else { pause().then(run) } - return promise + return thenable }, } } diff --git a/packages/query-core/src/thenable.ts b/packages/query-core/src/thenable.ts new file mode 100644 index 0000000000..56717f459d --- /dev/null +++ b/packages/query-core/src/thenable.ts @@ -0,0 +1,82 @@ +/** + * Thenable types which matches React's types for promises + * + * React seemingly uses `.status`, `.value` and `.reason` properties on a promises to optimistically unwrap data from promises + * + * @see https://github.com/facebook/react/blob/main/packages/shared/ReactTypes.js#L112-L138 + * @see https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-debug-tools/src/ReactDebugHooks.js#L224-L227 + */ + +interface Fulfilled { + status: 'fulfilled' + value: T +} +interface Rejected { + status: 'rejected' + reason: unknown +} +interface Pending { + status: 'pending' + + /** + * Resolve the promise with a value. + * Will remove the `resolve` and `reject` properties from the promise. + */ + resolve: (value: T) => void + /** + * Reject the promise with a reason. + * Will remove the `resolve` and `reject` properties from the promise. + */ + reject: (reason: unknown) => void +} + +export type FulfilledThenable = Promise & Fulfilled +export type RejectedThenable = Promise & Rejected +export type PendingThenable = Promise & Pending + +export type Thenable = + | FulfilledThenable + | RejectedThenable + | PendingThenable + +export function pendingThenable(): PendingThenable { + let resolve: Pending['resolve'] + let reject: Pending['reject'] + // this could use `Promise.withResolvers()` in the future + const thenable = new Promise((_resolve, _reject) => { + resolve = _resolve + reject = _reject + }) as PendingThenable + + thenable.status = 'pending' + thenable.catch(() => { + // prevent unhandled rejection errors + }) + + function finalize(data: Fulfilled | Rejected) { + Object.assign(thenable, data) + + // clear pending props props to avoid calling them twice + delete (thenable as Partial>).resolve + delete (thenable as Partial>).reject + } + + thenable.resolve = (value) => { + finalize({ + status: 'fulfilled', + value, + }) + + resolve(value) + } + thenable.reject = (reason) => { + finalize({ + status: 'rejected', + reason, + }) + + reject(reason) + } + + return thenable +} diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index fc553c74fc..b13e52d16c 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -380,6 +380,11 @@ export interface QueryObserverOptions< > _optimisticResults?: 'optimistic' | 'isRestoring' + + /** + * Enable prefetching during rendering + */ + experimental_prefetchInRender?: boolean } export type WithRequired = TTarget & { @@ -687,6 +692,55 @@ export interface QueryObserverBaseResult< * - See [Network Mode](https://tanstack.com/query/latest/docs/framework/react/guides/network-mode) for more information. */ fetchStatus: FetchStatus + /** + * A stable promise that will be resolved with the data of the query. + * Requires the `experimental_prefetchInRender` feature flag to be enabled. + * @example + * + * ### Enabling the feature flag + * ```ts + * const client = new QueryClient({ + * defaultOptions: { + * queries: { + * experimental_prefetchInRender: true, + * }, + * }, + * }) + * ``` + * + * ### Usage + * ```tsx + * import { useQuery } from '@tanstack/react-query' + * import React from 'react' + * import { fetchTodos, type Todo } from './api' + * + * function TodoList({ query }: { query: UseQueryResult }) { + * const data = React.use(query.promise) + * + * return ( + *
    + * {data.map(todo => ( + *
  • {todo.title}
  • + * ))} + *
+ * ) + * } + * + * export function App() { + * const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) + * + * return ( + * <> + *

Todos

+ * Loading...}> + * + * + * + * ) + * } + * ``` + */ + promise: Promise } export interface QueryObserverPendingResult< diff --git a/packages/react-query/package.json b/packages/react-query/package.json index 80e272535e..9186e62024 100644 --- a/packages/react-query/package.json +++ b/packages/react-query/package.json @@ -25,7 +25,7 @@ "test:types:ts51": "node ../../node_modules/typescript51/lib/tsc.js -p tsconfig.legacy.json", "test:types:ts52": "node ../../node_modules/typescript52/lib/tsc.js -p tsconfig.legacy.json", "test:types:ts53": "tsc", - "test:lib": "vitest --retry=3", + "test:lib": "vitest", "test:lib:dev": "pnpm run test:lib --watch", "test:build": "publint --strict && attw --pack", "build": "pnpm build:tsup && pnpm build:codemods", diff --git a/packages/react-query/src/__tests__/prefetch.test.tsx b/packages/react-query/src/__tests__/prefetch.test.tsx index 1b111b57ad..fc8ed8c08b 100644 --- a/packages/react-query/src/__tests__/prefetch.test.tsx +++ b/packages/react-query/src/__tests__/prefetch.test.tsx @@ -124,6 +124,8 @@ describe('usePrefetchQuery', () => { }) it('should let errors fall through and not refetch failed queries', async () => { + const consoleMock = vi.spyOn(console, 'error') + consoleMock.mockImplementation(() => undefined) const queryFn = generateQueryFn('Not an error') const queryOpts = { @@ -156,6 +158,8 @@ describe('usePrefetchQuery', () => { await waitFor(() => rendered.getByText('Oops!')) expect(rendered.queryByText('data: Not an error')).not.toBeInTheDocument() expect(queryOpts.queryFn).not.toHaveBeenCalled() + + consoleMock.mockRestore() }) it('should not create an endless loop when using inside a suspense boundary', async () => { @@ -187,6 +191,8 @@ describe('usePrefetchQuery', () => { }) it('should be able to recover from errors and try fetching again', async () => { + const consoleMock = vi.spyOn(console, 'error') + consoleMock.mockImplementation(() => undefined) const queryFn = generateQueryFn('This is fine :dog: :fire:') const queryOpts = { @@ -230,6 +236,7 @@ describe('usePrefetchQuery', () => { fireEvent.click(rendered.getByText('Try again')) await waitFor(() => rendered.getByText('data: This is fine :dog: :fire:')) expect(queryOpts.queryFn).toHaveBeenCalledTimes(1) + consoleMock.mockRestore() }) it('should not create a suspense waterfall if prefetch is fired', async () => { @@ -310,7 +317,9 @@ describe('usePrefetchInfiniteQuery', () => { return (
- {state.data.pages.map((page) => props.renderPage(page))} + {state.data.pages.map((page, index) => ( +
{props.renderPage(page)}
+ ))}
) diff --git a/packages/react-query/src/__tests__/ssr.test.tsx b/packages/react-query/src/__tests__/ssr.test.tsx index 97cf35c388..42a5f03326 100644 --- a/packages/react-query/src/__tests__/ssr.test.tsx +++ b/packages/react-query/src/__tests__/ssr.test.tsx @@ -1,10 +1,12 @@ -import { describe, expect, it, vi } from 'vitest' import * as React from 'react' import { renderToString } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' import { QueryCache, QueryClientProvider, useInfiniteQuery, useQuery } from '..' -import { createQueryClient, queryKey, sleep } from './utils' +import { createQueryClient, queryKey, setIsServer, sleep } from './utils' describe('Server Side Rendering', () => { + setIsServer(true) + it('should not trigger fetch', () => { const queryCache = new QueryCache() const queryClient = createQueryClient({ queryCache }) diff --git a/packages/react-query/src/__tests__/suspense.test.tsx b/packages/react-query/src/__tests__/suspense.test.tsx index a45aa516d2..13d4eadd8c 100644 --- a/packages/react-query/src/__tests__/suspense.test.tsx +++ b/packages/react-query/src/__tests__/suspense.test.tsx @@ -1220,7 +1220,6 @@ describe('useSuspenseQueries', () => { { - console.log('fallback renders') return
There was an error!
}} > diff --git a/packages/react-query/src/__tests__/useInfiniteQuery.test.tsx b/packages/react-query/src/__tests__/useInfiniteQuery.test.tsx index 7b20ff94c3..c1d20d0b70 100644 --- a/packages/react-query/src/__tests__/useInfiniteQuery.test.tsx +++ b/packages/react-query/src/__tests__/useInfiniteQuery.test.tsx @@ -42,7 +42,14 @@ const fetchItems = async ( describe('useInfiniteQuery', () => { const queryCache = new QueryCache() - const queryClient = createQueryClient({ queryCache }) + const queryClient = createQueryClient({ + queryCache, + defaultOptions: { + queries: { + experimental_prefetchInRender: true, + }, + }, + }) it('should return the correct states for a successful query', async () => { const key = queryKey() @@ -97,6 +104,7 @@ describe('useInfiniteQuery', () => { refetch: expect.any(Function), status: 'pending', fetchStatus: 'fetching', + promise: expect.any(Promise), }) expect(states[1]).toEqual({ @@ -132,6 +140,7 @@ describe('useInfiniteQuery', () => { refetch: expect.any(Function), status: 'success', fetchStatus: 'idle', + promise: expect.any(Promise), }) }) @@ -1778,4 +1787,71 @@ describe('useInfiniteQuery', () => { await waitFor(() => rendered.getByText('data: custom client')) }) + + it('should work with React.use()', async () => { + const key = queryKey() + + let pageRenderCount = 0 + let suspenseRenderCount = 0 + + function Loading() { + suspenseRenderCount++ + return <>loading... + } + function MyComponent() { + const fetchCountRef = React.useRef(0) + const query = useInfiniteQuery({ + queryFn: ({ pageParam }) => + fetchItems(pageParam, fetchCountRef.current++), + getNextPageParam: (lastPage) => lastPage.nextId, + initialPageParam: 0, + queryKey: key, + }) + const data = React.use(query.promise) + return ( + <> + {data.pages.map((page, index) => ( + +
+
Page: {index + 1}
+
+ {page.items.map((item) => ( +

Item: {item}

+ ))} +
+ ))} + + + ) + } + function Page() { + pageRenderCount++ + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading...')) + + await waitFor(() => rendered.getByText('Page: 1')) + await waitFor(() => rendered.getByText('Item: 1')) + + expect(rendered.queryByText('Page: 2')).toBeNull() + expect(pageRenderCount).toBe(1) + + // click button + fireEvent.click(rendered.getByRole('button', { name: 'fetchNextPage' })) + + await waitFor(() => { + expect(rendered.queryByText('Page: 2')).not.toBeNull() + }) + + // Suspense doesn't trigger when fetching next page + expect(suspenseRenderCount).toBe(1) + + expect(pageRenderCount).toBe(1) + }) }) diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index fad1aee708..cbe64c69fe 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -1,4 +1,13 @@ -import { describe, expect, expectTypeOf, it, test, vi } from 'vitest' +import { + afterAll, + beforeAll, + describe, + expect, + expectTypeOf, + it, + test, + vi, +} from 'vitest' import { act, fireEvent, render, waitFor } from '@testing-library/react' import * as React from 'react' import { ErrorBoundary } from 'react-error-boundary' @@ -25,7 +34,9 @@ import type { Mock } from 'vitest' describe('useQuery', () => { const queryCache = new QueryCache() - const queryClient = createQueryClient({ queryCache }) + const queryClient = createQueryClient({ + queryCache, + }) it('should return the correct types', () => { const key = queryKey() @@ -41,6 +52,7 @@ describe('useQuery', () => { const fromQueryFn = useQuery({ queryKey: key, queryFn: () => 'test' }) expectTypeOf(fromQueryFn.data).toEqualTypeOf() expectTypeOf(fromQueryFn.error).toEqualTypeOf() + expectTypeOf(fromQueryFn.promise).toEqualTypeOf>() // it should be possible to specify the result type const withResult = useQuery({ @@ -270,6 +282,7 @@ describe('useQuery', () => { refetch: expect.any(Function), status: 'pending', fetchStatus: 'fetching', + promise: expect.any(Promise), }) expect(states[1]).toEqual({ @@ -297,18 +310,22 @@ describe('useQuery', () => { refetch: expect.any(Function), status: 'success', fetchStatus: 'idle', + promise: expect.any(Promise), }) + + expect(states[0]!.promise).toEqual(states[1]!.promise) }) it('should return the correct states for an unsuccessful query', async () => { const key = queryKey() const states: Array = [] + let index = 0 function Page() { const state = useQuery({ queryKey: key, - queryFn: () => Promise.reject(new Error('rejected')), + queryFn: () => Promise.reject(new Error(`rejected #${++index}`)), retry: 1, retryDelay: 1, @@ -354,6 +371,7 @@ describe('useQuery', () => { refetch: expect.any(Function), status: 'pending', fetchStatus: 'fetching', + promise: expect.any(Promise), }) expect(states[1]).toEqual({ @@ -362,7 +380,7 @@ describe('useQuery', () => { error: null, errorUpdatedAt: 0, failureCount: 1, - failureReason: new Error('rejected'), + failureReason: new Error('rejected #1'), errorUpdateCount: 0, isError: false, isFetched: false, @@ -381,15 +399,16 @@ describe('useQuery', () => { refetch: expect.any(Function), status: 'pending', fetchStatus: 'fetching', + promise: expect.any(Promise), }) expect(states[2]).toEqual({ data: undefined, dataUpdatedAt: 0, - error: new Error('rejected'), + error: new Error('rejected #2'), errorUpdatedAt: expect.any(Number), failureCount: 2, - failureReason: new Error('rejected'), + failureReason: new Error('rejected #2'), errorUpdateCount: 1, isError: true, isFetched: true, @@ -408,7 +427,11 @@ describe('useQuery', () => { refetch: expect.any(Function), status: 'error', fetchStatus: 'idle', + promise: expect.any(Promise), }) + + expect(states[0]!.promise).toEqual(states[1]!.promise) + expect(states[1]!.promise).toEqual(states[2]!.promise) }) it('should set isFetchedAfterMount to true after a query has been fetched', async () => { @@ -659,7 +682,7 @@ describe('useQuery', () => { }, gcTime: 0, - notifyOnChangeProps: 'all', + notifyOnChangeProps: ['isPending', 'isSuccess', 'data'], }) states.push(state) @@ -697,13 +720,29 @@ describe('useQuery', () => { expect(states.length).toBe(4) // First load - expect(states[0]).toMatchObject({ isPending: true, isSuccess: false }) + expect(states[0]).toMatchObject({ + isPending: true, + isSuccess: false, + data: undefined, + }) // First success - expect(states[1]).toMatchObject({ isPending: false, isSuccess: true }) + expect(states[1]).toMatchObject({ + isPending: false, + isSuccess: true, + data: 'data', + }) // Remove - expect(states[2]).toMatchObject({ isPending: true, isSuccess: false }) + expect(states[2]).toMatchObject({ + isPending: true, + isSuccess: false, + data: undefined, + }) // Second success - expect(states[3]).toMatchObject({ isPending: false, isSuccess: true }) + expect(states[3]).toMatchObject({ + isPending: false, + isSuccess: true, + data: 'data', + }) }) it('should fetch when refetchOnMount is false and nothing has been fetched yet', async () => { @@ -6581,4 +6620,747 @@ describe('useQuery', () => { consoleMock.mockRestore() }) + + describe('useQuery().promise', () => { + beforeAll(() => { + queryClient.setDefaultOptions({ + queries: { experimental_prefetchInRender: true }, + }) + }) + afterAll(() => { + queryClient.setDefaultOptions({ + queries: { experimental_prefetchInRender: false }, + }) + }) + it('should work with a basic test', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + suspenseRenderCount++ + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return 'test' + }, + }) + + pageRenderCount++ + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test')) + + // Suspense should rendered once since `.promise` is the only watched property + expect(suspenseRenderCount).toBe(1) + + // Page should be rendered once since since the promise do not change + expect(pageRenderCount).toBe(1) + }) + + it('colocate suspense and promise', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + let callCount = 0 + + function MyComponent() { + 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() { + suspenseRenderCount++ + return <>loading.. + } + function Page() { + pageRenderCount++ + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test')) + + // Suspense should rendered once since `.promise` is the only watched property + expect(suspenseRenderCount).toBe(1) + + // Page should be rendered once since since the promise do not change + expect(pageRenderCount).toBe(1) + + expect(callCount).toBe(1) + }) + + it('parallel queries', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + let callCount = 0 + + function MyComponent() { + 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() { + suspenseRenderCount++ + return <>loading.. + } + function Page() { + pageRenderCount++ + return ( + <> + }> + + + + + + + + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => { + expect(rendered.queryByText('loading..')).not.toBeInTheDocument() + }) + + expect(rendered.container.textContent).toBe('test'.repeat(5)) + + // Suspense should rendered once since `.promise` is the only watched property + expect(suspenseRenderCount).toBe(1) + + // Page should be rendered once since since the promise do not change + expect(pageRenderCount).toBe(1) + + expect(callCount).toBe(1) + }) + + it('should work with initial data', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + suspenseRenderCount++ + + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return 'test' + }, + initialData: 'initial', + }) + pageRenderCount++ + + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('initial')) + await waitFor(() => rendered.getByText('test')) + + // Suspense boundary should never be rendered since it has data immediately + expect(suspenseRenderCount).toBe(0) + // Page should only be rendered twice since, the promise will get swapped out when new result comes in + expect(pageRenderCount).toBe(2) + }) + + it('should not fetch with initial data and staleTime', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(1) + return 'test' + }) + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + suspenseRenderCount++ + + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn, + initialData: 'initial', + staleTime: 1000, + }) + + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('initial')) + + // Suspense boundary should never be rendered since it has data immediately + expect(suspenseRenderCount).toBe(0) + // should not call queryFn because of staleTime + initialData combo + expect(queryFn).toHaveBeenCalledTimes(0) + }) + + it('should work with static placeholderData', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + suspenseRenderCount++ + + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return 'test' + }, + placeholderData: 'placeholder', + }) + pageRenderCount++ + + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('placeholder')) + await waitFor(() => rendered.getByText('test')) + + // Suspense boundary should never be rendered since it has data immediately + expect(suspenseRenderCount).toBe(0) + // Page should only be rendered twice since, the promise will get swapped out when new result comes in + expect(pageRenderCount).toBe(2) + }) + + it('should work with placeholderData: keepPreviousData', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + function Loading() { + suspenseRenderCount++ + + return <>loading.. + } + function Page() { + 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 = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test-0')) + + // Suspense boundary should only be rendered initially + expect(suspenseRenderCount).toBe(1) + + fireEvent.click(rendered.getByRole('button', { name: 'increment' })) + + await waitFor(() => rendered.getByText('test-1')) + + // no more suspense boundary rendering + expect(suspenseRenderCount).toBe(1) + }) + + it('should be possible to select a part of the data with select', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + return <>{data} + } + + function Loading() { + suspenseRenderCount++ + return <>loading.. + } + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return { name: 'test' } + }, + select: (data) => data.name, + }) + + pageRenderCount++ + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => { + rendered.getByText('test') + }) + expect(suspenseRenderCount).toBe(1) + expect(pageRenderCount).toBe(1) + }) + + it('should throw error if the promise fails', async () => { + let suspenseRenderCount = 0 + 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() { + suspenseRenderCount++ + 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 ( + }> + + + ) + } + + const rendered = renderWithClient( + queryClient, + ( + <> + error boundary{' '} + + + )} + > + + , + ) + + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('error boundary')) + + consoleMock.mockRestore() + + fireEvent.click(rendered.getByText('resetErrorBoundary')) + + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('data')) + + expect(queryCount).toBe(2) + }) + + it('should recreate promise with data changes', async () => { + const key = queryKey() + let suspenseRenderCount = 0 + let pageRenderCount = 0 + + function MyComponent(props: { promise: Promise }) { + const data = React.use(props.promise) + + return <>{data} + } + + function Loading() { + suspenseRenderCount++ + return <>loading.. + } + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(1) + return 'test1' + }, + }) + + pageRenderCount++ + return ( + }> + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test1')) + + // Suspense should rendered once since `.promise` is the only watched property + expect(pageRenderCount).toBe(1) + + queryClient.setQueryData(key, 'test2') + + await waitFor(() => rendered.getByText('test2')) + + // Suspense should rendered once since `.promise` is the only watched property + expect(suspenseRenderCount).toBe(1) + + // Page should be rendered once since since the promise changed once + expect(pageRenderCount).toBe(2) + }) + + it('should dedupe when re-fetched with queryClient.fetchQuery while suspending', async () => { + const key = queryKey() + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(10) + return 'test' + }) + + 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 = renderWithClient(queryClient, ) + fireEvent.click(rendered.getByText('fetch')) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test')) + + expect(queryFn).toHaveBeenCalledOnce() + }) + + it('should dedupe when re-fetched with refetchQueries while suspending', async () => { + const key = queryKey() + let count = 0 + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(10) + return 'test' + count++ + }) + + 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 = renderWithClient(queryClient, ) + fireEvent.click(rendered.getByText('refetch')) + await waitFor(() => rendered.getByText('loading..')) + await waitFor(() => rendered.getByText('test0')) + + expect(queryFn).toHaveBeenCalledOnce() + }) + + it('should stay pending when canceled with cancelQueries while suspending until refetched', async () => { + const key = queryKey() + let count = 0 + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(10) + return 'test' + count++ + }) + + 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 = renderWithClient( + queryClient, + <>error boundary}> + + , + ) + fireEvent.click(rendered.getByText('cancel')) + await waitFor(() => rendered.getByText('loading..')) + // await waitFor(() => rendered.getByText('error boundary')) + await waitFor(() => + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'pending', + fetchStatus: 'idle', + }), + ) + + expect(queryFn).toHaveBeenCalledOnce() + + fireEvent.click(rendered.getByText('fetch')) + + await waitFor(() => rendered.getByText('hello')) + }) + + it('should resolve to previous data when canceled with cancelQueries while suspending', async () => { + const key = queryKey() + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(10) + return 'test' + }) + + 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 ( +
+ }> + + + +
+ ) + } + + queryClient.setQueryData(key, 'initial') + + const rendered = renderWithClient(queryClient, ) + fireEvent.click(rendered.getByText('cancel')) + await waitFor(() => rendered.getByText('initial')) + + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should suspend when not enabled', async () => { + const key = queryKey() + + const options = (count: number) => ({ + queryKey: [...key, count], + queryFn: async () => { + await sleep(10) + return 'test' + count + }, + }) + + 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 = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('loading..')) + fireEvent.click(rendered.getByText('enable')) + await waitFor(() => rendered.getByText('test1')) + }) + }) }) diff --git a/packages/react-query/src/types.ts b/packages/react-query/src/types.ts index 0979fc5311..59a57e728c 100644 --- a/packages/react-query/src/types.ts +++ b/packages/react-query/src/types.ts @@ -101,7 +101,10 @@ export type UseQueryResult< export type UseSuspenseQueryResult< TData = unknown, TError = DefaultError, -> = OmitKeyof, 'isPlaceholderData'> +> = OmitKeyof< + DefinedQueryObserverResult, + 'isPlaceholderData' | 'promise' +> export type DefinedUseQueryResult< TData = unknown, @@ -123,7 +126,7 @@ export type UseSuspenseInfiniteQueryResult< TError = DefaultError, > = OmitKeyof< DefinedInfiniteQueryObserverResult, - 'isPlaceholderData' + 'isPlaceholderData' | 'promise' > export interface UseMutationOptions< diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index f6b632c8ff..32162bc87d 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -1,27 +1,29 @@ 'use client' import * as React from 'react' -import { notifyManager } from '@tanstack/query-core' -import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary' +import { isServer, notifyManager } from '@tanstack/query-core' import { useQueryClient } from './QueryClientProvider' -import { useIsRestoring } from './isRestoring' +import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary' import { ensurePreventErrorBoundaryRetry, getHasError, useClearResetErrorBoundary, } from './errorBoundaryUtils' +import { useIsRestoring } from './isRestoring' import { ensureSuspenseTimers, fetchOptimistic, shouldSuspend, + willFetch, } from './suspense' -import type { UseBaseQueryOptions } from './types' +import { noop } from './utils' import type { QueryClient, QueryKey, QueryObserver, QueryObserverResult, } from '@tanstack/query-core' +import type { UseBaseQueryOptions } from './types' export function useBaseQuery< TQueryFnData, @@ -67,6 +69,9 @@ export function useBaseQuery< useClearResetErrorBoundary(errorResetBoundary) + // this needs to be invoked before creating the Observer because that can create a cache entry + const isNewCacheEntry = !client.getQueryState(options.queryKey) + const [observer] = React.useState( () => new Observer( @@ -131,6 +136,25 @@ export function useBaseQuery< result, ) + if ( + defaultedOptions.experimental_prefetchInRender && + !isServer && + willFetch(result, isRestoring) + ) { + const promise = isNewCacheEntry + ? // Fetch immediately on render in order to ensure `.promise` is resolved even if the component is unmounted + fetchOptimistic(defaultedOptions, observer, errorResetBoundary) + : // subscribe to the "cache promise" so that we can finalize the currentThenable once data comes in + client.getQueryCache().get(defaultedOptions.queryHash)?.promise + + promise?.catch(noop).finally(() => { + if (!observer.hasListeners()) { + // `.updateResult()` will trigger `.#currentThenable` to finalize + observer.updateResult() + } + }) + } + // Handle result property usage tracking return !defaultedOptions.notifyOnChangeProps ? observer.trackResult(result) diff --git a/packages/react-query/vite.config.ts b/packages/react-query/vite.config.ts index f42977d0b1..fba5f8d044 100644 --- a/packages/react-query/vite.config.ts +++ b/packages/react-query/vite.config.ts @@ -13,5 +13,6 @@ export default defineConfig({ coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] }, typecheck: { enabled: true }, restoreMocks: true, + retry: process.env.CI ? 3 : 0, }, }) diff --git a/packages/solid-query/src/__tests__/createInfiniteQuery.test.tsx b/packages/solid-query/src/__tests__/createInfiniteQuery.test.tsx index 006cc165c7..1bbb2fbcc0 100644 --- a/packages/solid-query/src/__tests__/createInfiniteQuery.test.tsx +++ b/packages/solid-query/src/__tests__/createInfiniteQuery.test.tsx @@ -120,6 +120,7 @@ describe('useInfiniteQuery', () => { refetch: expect.any(Function), status: 'pending', fetchStatus: 'fetching', + promise: expect.any(Promise), }) expect(states[1]).toEqual({ @@ -155,6 +156,7 @@ describe('useInfiniteQuery', () => { refetch: expect.any(Function), status: 'success', fetchStatus: 'idle', + promise: expect.any(Promise), }) }) diff --git a/packages/solid-query/src/__tests__/createQuery.test.tsx b/packages/solid-query/src/__tests__/createQuery.test.tsx index 61b95b3e3e..60e1df1aef 100644 --- a/packages/solid-query/src/__tests__/createQuery.test.tsx +++ b/packages/solid-query/src/__tests__/createQuery.test.tsx @@ -304,6 +304,7 @@ describe('createQuery', () => { refetch: expect.any(Function), status: 'pending', fetchStatus: 'fetching', + promise: expect.any(Promise), }) expect(states[1]).toEqual({ @@ -331,6 +332,7 @@ describe('createQuery', () => { refetch: expect.any(Function), status: 'success', fetchStatus: 'idle', + promise: expect.any(Promise), }) }) @@ -393,6 +395,7 @@ describe('createQuery', () => { refetch: expect.any(Function), status: 'pending', fetchStatus: 'fetching', + promise: expect.any(Promise), }) expect(states[1]).toEqual({ @@ -420,6 +423,7 @@ describe('createQuery', () => { refetch: expect.any(Function), status: 'pending', fetchStatus: 'fetching', + promise: expect.any(Promise), }) expect(states[2]).toEqual({ @@ -447,6 +451,7 @@ describe('createQuery', () => { refetch: expect.any(Function), status: 'error', fetchStatus: 'idle', + promise: expect.any(Promise), }) }) diff --git a/packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.test.ts b/packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.test.ts index b05763b279..d80bc9c706 100644 --- a/packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.test.ts +++ b/packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.test.ts @@ -57,6 +57,7 @@ describe('createInfiniteQuery', () => { refetch: expect.any(Function), status: 'pending', fetchStatus: 'fetching', + promise: expect.any(Promise), }) expect(states[1]).toEqual({ @@ -92,6 +93,7 @@ describe('createInfiniteQuery', () => { refetch: expect.any(Function), status: 'success', fetchStatus: 'idle', + promise: expect.any(Promise), }) })