snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
171
packages/react-query/src/HydrationBoundary.tsx
Normal file
171
packages/react-query/src/HydrationBoundary.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
|
||||
import { hydrate } from '@tanstack/query-core'
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
import type {
|
||||
DehydratedState,
|
||||
HydrateOptions,
|
||||
OmitKeyof,
|
||||
QueryClient,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
/**
|
||||
* The props accepted by `HydrationBoundary`.
|
||||
*/
|
||||
export interface HydrationBoundaryProps {
|
||||
/**
|
||||
* The state to hydrate.
|
||||
*/
|
||||
state: DehydratedState | null | undefined
|
||||
/**
|
||||
* Optional. Note: unlike `hydrate`, `mutations` cannot be set here.
|
||||
*/
|
||||
options?: OmitKeyof<HydrateOptions, 'defaultOptions'> & {
|
||||
defaultOptions?: OmitKeyof<
|
||||
Exclude<HydrateOptions['defaultOptions'], undefined>,
|
||||
'mutations'
|
||||
>
|
||||
}
|
||||
/**
|
||||
* The components to render — always rendered unconditionally, not gated on hydration. New queries are
|
||||
* hydrated into the cache during render; for queries that already exist in the cache, only newer dehydrated
|
||||
* data is hydrated, and that happens in an effect after commit, so `children` may render briefly before it
|
||||
* lands.
|
||||
*/
|
||||
children?: React.ReactNode
|
||||
/**
|
||||
* Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will be used.
|
||||
*/
|
||||
queryClient?: QueryClient
|
||||
}
|
||||
|
||||
/**
|
||||
* `HydrationBoundary` adds a previously dehydrated state into the `queryClient` that would be returned by
|
||||
* `useQueryClient()`. If the client already contains data, the new queries will be intelligently merged based on
|
||||
* update timestamp.
|
||||
*
|
||||
* Note: Only `queries` can be dehydrated with an `HydrationBoundary`.
|
||||
*
|
||||
* @returns The provided `children`, rendered unconditionally. New queries in `state` are hydrated into the
|
||||
* cache during render; for queries already in the cache, only newer dehydrated data is hydrated, in an effect
|
||||
* after commit.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { HydrationBoundary } from '@tanstack/react-query'
|
||||
*
|
||||
* function App() {
|
||||
* return <HydrationBoundary state={dehydratedState}>...</HydrationBoundary>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Server-side prefetch handed off to the client via `dehydrate`:
|
||||
* ```tsx
|
||||
* import { HydrationBoundary, dehydrate, noop } from '@tanstack/react-query'
|
||||
*
|
||||
* async function ServerComponent() {
|
||||
* const queryClient = getQueryClient()
|
||||
*
|
||||
* await queryClient
|
||||
* .query({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* })
|
||||
* .catch(noop)
|
||||
*
|
||||
* return (
|
||||
* <HydrationBoundary state={dehydrate(queryClient)}>
|
||||
* <Posts />
|
||||
* </HydrationBoundary>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const HydrationBoundary = ({
|
||||
children,
|
||||
options = {},
|
||||
state,
|
||||
queryClient,
|
||||
}: HydrationBoundaryProps) => {
|
||||
const client = useQueryClient(queryClient)
|
||||
|
||||
const optionsRef = React.useRef(options)
|
||||
React.useEffect(() => {
|
||||
optionsRef.current = options
|
||||
})
|
||||
|
||||
// This useMemo is for performance reasons only, everything inside it must
|
||||
// be safe to run in every render and code here should be read as "in render".
|
||||
//
|
||||
// This code needs to happen during the render phase, because after initial
|
||||
// SSR, hydration needs to happen _before_ children render. Also, if hydrating
|
||||
// during a transition, we want to hydrate as much as is safe in render so
|
||||
// we can prerender as much as possible.
|
||||
//
|
||||
// For any queries that already exist in the cache, we want to hold back on
|
||||
// hydrating until _after_ the render phase. The reason for this is that during
|
||||
// transitions, we don't want the existing queries and observers to update to
|
||||
// the new data on the current page, only _after_ the transition is committed.
|
||||
// If the transition is aborted, we will have hydrated any _new_ queries, but
|
||||
// we throw away the fresh data for any existing ones to avoid unexpectedly
|
||||
// updating the UI.
|
||||
const hydrationQueue: DehydratedState['queries'] | undefined =
|
||||
React.useMemo(() => {
|
||||
if (state) {
|
||||
if (typeof state !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const queryCache = client.getQueryCache()
|
||||
// State is supplied from the outside and we might as well fail
|
||||
// gracefully if it has the wrong shape, so while we type `queries`
|
||||
// as required, we still provide a fallback.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const queries = state.queries || []
|
||||
|
||||
const newQueries: DehydratedState['queries'] = []
|
||||
const existingQueries: DehydratedState['queries'] = []
|
||||
for (const dehydratedQuery of queries) {
|
||||
const existingQuery = queryCache.get(dehydratedQuery.queryHash)
|
||||
|
||||
if (!existingQuery) {
|
||||
newQueries.push(dehydratedQuery)
|
||||
} else {
|
||||
const hydrationIsNewer =
|
||||
dehydratedQuery.state.dataUpdatedAt >
|
||||
existingQuery.state.dataUpdatedAt ||
|
||||
(dehydratedQuery.promise &&
|
||||
existingQuery.state.status !== 'pending' &&
|
||||
existingQuery.state.fetchStatus !== 'fetching' &&
|
||||
dehydratedQuery.dehydratedAt >
|
||||
existingQuery.state.dataUpdatedAt)
|
||||
|
||||
if (hydrationIsNewer) {
|
||||
existingQueries.push(dehydratedQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newQueries.length > 0) {
|
||||
// It's actually fine to call this with queries/state that already exists
|
||||
// in the cache, or is older. hydrate() is idempotent for queries.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
hydrate(client, { queries: newQueries }, optionsRef.current)
|
||||
}
|
||||
if (existingQueries.length > 0) {
|
||||
return existingQueries
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}, [client, state])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hydrationQueue) {
|
||||
hydrate(client, { queries: hydrationQueue }, optionsRef.current)
|
||||
}
|
||||
}, [client, hydrationQueue])
|
||||
|
||||
return children as React.ReactElement
|
||||
}
|
||||
19
packages/react-query/src/IsRestoringProvider.ts
Normal file
19
packages/react-query/src/IsRestoringProvider.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
|
||||
const IsRestoringContext = React.createContext(false)
|
||||
|
||||
/**
|
||||
* If you are using `PersistQueryClientProvider`, you can also use the `useIsRestoring` hook alongside it to
|
||||
* check if a restore is currently in progress. `useQuery` and friends also check this internally to avoid
|
||||
* race conditions between the restore and mounting queries.
|
||||
*
|
||||
* @returns `true` while a persisted client is being restored, `false` otherwise.
|
||||
*/
|
||||
export const useIsRestoring = () => React.useContext(IsRestoringContext)
|
||||
|
||||
/**
|
||||
* The Provider that `PersistQueryClientProvider` uses to signal whether a persisted client is currently
|
||||
* being restored, read by `useIsRestoring`.
|
||||
*/
|
||||
export const IsRestoringProvider = IsRestoringContext.Provider
|
||||
86
packages/react-query/src/QueryClientProvider.tsx
Normal file
86
packages/react-query/src/QueryClientProvider.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
|
||||
import type { QueryClient } from '@tanstack/query-core'
|
||||
|
||||
/**
|
||||
* The context that `useQueryClient` reads from. `QueryClientProvider` is the normal way to set it.
|
||||
*/
|
||||
export const QueryClientContext = React.createContext<QueryClient | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
/**
|
||||
* The `useQueryClient` hook returns the current `QueryClient` instance.
|
||||
*
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The current `QueryClient` instance.
|
||||
* @throws If no `queryClient` argument is passed and no `QueryClientProvider` is found in the component tree.
|
||||
*/
|
||||
export const useQueryClient = (queryClient?: QueryClient) => {
|
||||
const client = React.useContext(QueryClientContext)
|
||||
|
||||
if (queryClient) {
|
||||
return queryClient
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
throw new Error('No QueryClient set, use QueryClientProvider to set one')
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* The props accepted by `QueryClientProvider`.
|
||||
*/
|
||||
export type QueryClientProviderProps = {
|
||||
/**
|
||||
* **Required**
|
||||
*
|
||||
* The `QueryClient` instance to provide.
|
||||
*/
|
||||
client: QueryClient
|
||||
/**
|
||||
* The components that get access to the provided `QueryClient`.
|
||||
*/
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the `QueryClientProvider` component to connect and provide a `QueryClient` to your application. Also
|
||||
* calls `client.mount()`/`client.unmount()` as this component mounts/unmounts, which subscribes the client to
|
||||
* focus/online events (resuming any paused mutations and refetching as needed when the app regains focus or
|
||||
* comes back online).
|
||||
*
|
||||
* @returns The provided `children`, wrapped so they can read the `QueryClient` via `useQueryClient`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
*
|
||||
* const queryClient = new QueryClient()
|
||||
*
|
||||
* function App() {
|
||||
* return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const QueryClientProvider = ({
|
||||
client,
|
||||
children,
|
||||
}: QueryClientProviderProps): React.JSX.Element => {
|
||||
React.useEffect(() => {
|
||||
client.mount()
|
||||
return () => {
|
||||
client.unmount()
|
||||
}
|
||||
}, [client])
|
||||
|
||||
return (
|
||||
<QueryClientContext.Provider value={client}>
|
||||
{children}
|
||||
</QueryClientContext.Provider>
|
||||
)
|
||||
}
|
||||
145
packages/react-query/src/QueryErrorResetBoundary.tsx
Normal file
145
packages/react-query/src/QueryErrorResetBoundary.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
|
||||
// CONTEXT
|
||||
export type QueryErrorResetFunction = () => void
|
||||
export type QueryErrorIsResetFunction = () => boolean
|
||||
export type QueryErrorClearResetFunction = () => void
|
||||
|
||||
export interface QueryErrorResetBoundaryValue {
|
||||
clearReset: QueryErrorClearResetFunction
|
||||
isReset: QueryErrorIsResetFunction
|
||||
reset: QueryErrorResetFunction
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets any query errors within the boundary, so queries know they can try again.
|
||||
*/
|
||||
function createValue(): QueryErrorResetBoundaryValue {
|
||||
let isReset = false
|
||||
return {
|
||||
/**
|
||||
* Clears the reset state, so queries know not to try again until the boundary is reset again.
|
||||
*/
|
||||
clearReset: () => {
|
||||
isReset = false
|
||||
},
|
||||
/**
|
||||
* Resets any query errors within the boundary, so queries know they can try again.
|
||||
*/
|
||||
reset: () => {
|
||||
isReset = true
|
||||
},
|
||||
/**
|
||||
* Returns whether the boundary has been reset and not yet cleared.
|
||||
*/
|
||||
isReset: () => {
|
||||
return isReset
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const QueryErrorResetBoundaryContext = React.createContext(createValue())
|
||||
|
||||
// HOOK
|
||||
|
||||
/**
|
||||
* This hook will reset any query errors within the closest `QueryErrorResetBoundary`. If there is no boundary
|
||||
* defined it will reset them globally.
|
||||
*
|
||||
* @returns The boundary's {@link QueryErrorResetBoundaryValue}.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { ErrorBoundary } from 'react-error-boundary'
|
||||
* import { useQueryErrorResetBoundary } from '@tanstack/react-query'
|
||||
*
|
||||
* function App({ children }: { children: React.ReactNode }) {
|
||||
* const { reset } = useQueryErrorResetBoundary()
|
||||
*
|
||||
* return (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* {children}
|
||||
* </ErrorBoundary>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const useQueryErrorResetBoundary = () =>
|
||||
React.useContext(QueryErrorResetBoundaryContext)
|
||||
|
||||
// COMPONENT
|
||||
|
||||
/**
|
||||
* A render-prop function usable as `children` on `QueryErrorResetBoundary`.
|
||||
*
|
||||
* @param value - The boundary's {@link QueryErrorResetBoundaryValue}.
|
||||
* @returns The children to render.
|
||||
*/
|
||||
export type QueryErrorResetBoundaryFunction = (
|
||||
value: QueryErrorResetBoundaryValue,
|
||||
) => React.ReactNode
|
||||
|
||||
/**
|
||||
* The props accepted by `QueryErrorResetBoundary`.
|
||||
*/
|
||||
export interface QueryErrorResetBoundaryProps {
|
||||
/**
|
||||
* Either a plain node, or a function that receives the boundary's {@link QueryErrorResetBoundaryValue} and
|
||||
* returns a node.
|
||||
*/
|
||||
children: QueryErrorResetBoundaryFunction | React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* When using `suspense` or `throwOnError` in your queries, you need a way to let queries know that you want to
|
||||
* try again when re-rendering after some error occurred. With the `QueryErrorResetBoundary` component you can
|
||||
* reset any query errors within the boundaries of the component.
|
||||
*
|
||||
* @returns The `children`, rendered as-is, or called with the boundary's {@link QueryErrorResetBoundaryValue}
|
||||
* if `children` is a function.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { ErrorBoundary } from 'react-error-boundary'
|
||||
* import { QueryErrorResetBoundary } from '@tanstack/react-query'
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Page />
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const QueryErrorResetBoundary = ({
|
||||
children,
|
||||
}: QueryErrorResetBoundaryProps) => {
|
||||
const [value] = React.useState(() => createValue())
|
||||
return (
|
||||
<QueryErrorResetBoundaryContext.Provider value={value}>
|
||||
{typeof children === 'function' ? children(value) : children}
|
||||
</QueryErrorResetBoundaryContext.Provider>
|
||||
)
|
||||
}
|
||||
558
packages/react-query/src/__tests__/HydrationBoundary.test.tsx
Normal file
558
packages/react-query/src/__tests__/HydrationBoundary.test.tsx
Normal file
@@ -0,0 +1,558 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as React from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import * as coreModule from '@tanstack/query-core'
|
||||
import { sleep } from '@tanstack/query-test-utils'
|
||||
import {
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
dehydrate,
|
||||
noop,
|
||||
useQuery,
|
||||
} from '..'
|
||||
import type { hydrate } from '@tanstack/query-core'
|
||||
|
||||
describe('React hydration', () => {
|
||||
let stringifiedState: string
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers()
|
||||
const queryClient = new QueryClient()
|
||||
void queryClient
|
||||
.query({
|
||||
queryKey: ['string'],
|
||||
queryFn: () => sleep(10).then(() => ['stringCached']),
|
||||
})
|
||||
.catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
const dehydrated = dehydrate(queryClient)
|
||||
stringifiedState = JSON.stringify(dehydrated)
|
||||
queryClient.clear()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should hydrate queries to the cache on context', async () => {
|
||||
const dehydratedState = JSON.parse(stringifiedState)
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['string'],
|
||||
queryFn: () => sleep(20).then(() => ['string']),
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('stringCached')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(21)
|
||||
expect(rendered.getByText('string')).toBeInTheDocument()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('should hydrate queries to the cache on custom context', async () => {
|
||||
const queryClientInner = new QueryClient()
|
||||
const queryClientOuter = new QueryClient()
|
||||
|
||||
const dehydratedState = JSON.parse(stringifiedState)
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['string'],
|
||||
queryFn: () => sleep(20).then(() => ['string']),
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClientOuter}>
|
||||
<QueryClientProvider client={queryClientInner}>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('stringCached')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(21)
|
||||
expect(rendered.getByText('string')).toBeInTheDocument()
|
||||
|
||||
queryClientInner.clear()
|
||||
queryClientOuter.clear()
|
||||
})
|
||||
|
||||
describe('ReactQueryCacheProvider with hydration support', () => {
|
||||
it('should hydrate new queries if queries change', async () => {
|
||||
const dehydratedState = JSON.parse(stringifiedState)
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function Page({ queryKey }: { queryKey: [string] }) {
|
||||
const { data } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => sleep(20).then(() => queryKey),
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<Page queryKey={['string']} />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('stringCached')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(21)
|
||||
expect(rendered.getByText('string')).toBeInTheDocument()
|
||||
|
||||
const intermediateClient = new QueryClient()
|
||||
|
||||
void intermediateClient
|
||||
.query({
|
||||
queryKey: ['string'],
|
||||
queryFn: () => sleep(20).then(() => ['should change']),
|
||||
})
|
||||
.catch(noop)
|
||||
void intermediateClient
|
||||
.query({
|
||||
queryKey: ['added'],
|
||||
queryFn: () => sleep(20).then(() => ['added']),
|
||||
})
|
||||
.catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
const dehydrated = dehydrate(intermediateClient)
|
||||
intermediateClient.clear()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={dehydrated}>
|
||||
<Page queryKey={['string']} />
|
||||
<Page queryKey={['added']} />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
// Existing observer should not have updated at this point,
|
||||
// as that would indicate a side effect in the render phase
|
||||
expect(rendered.getByText('string')).toBeInTheDocument()
|
||||
// New query data should be available immediately
|
||||
expect(rendered.getByText('added')).toBeInTheDocument()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
// After effects phase has had time to run, the observer should have updated
|
||||
expect(rendered.queryByText('string')).not.toBeInTheDocument()
|
||||
expect(rendered.getByText('should change')).toBeInTheDocument()
|
||||
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
// When we hydrate in transitions that are later aborted, it could be
|
||||
// confusing to both developers and users if we suddenly updated existing
|
||||
// state on the screen (why did this update when it was not stale, nothing
|
||||
// remounted, I didn't change tabs etc?).
|
||||
// Any queries that does not exist in the cache yet can still be hydrated
|
||||
// since they don't have any observers on the current page that would update.
|
||||
it('should hydrate new but not existing queries if transition is aborted', async () => {
|
||||
const initialDehydratedState = JSON.parse(stringifiedState)
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function Page({ queryKey }: { queryKey: [string] }) {
|
||||
const { data } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => sleep(20).then(() => queryKey),
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={initialDehydratedState}>
|
||||
<Page queryKey={['string']} />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('stringCached')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(21)
|
||||
expect(rendered.getByText('string')).toBeInTheDocument()
|
||||
|
||||
const intermediateClient = new QueryClient()
|
||||
void intermediateClient
|
||||
.query({
|
||||
queryKey: ['string'],
|
||||
queryFn: () => sleep(20).then(() => ['should not change']),
|
||||
})
|
||||
.catch(noop)
|
||||
void intermediateClient
|
||||
.query({
|
||||
queryKey: ['added'],
|
||||
queryFn: () => sleep(20).then(() => ['added']),
|
||||
})
|
||||
.catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
|
||||
const newDehydratedState = dehydrate(intermediateClient)
|
||||
intermediateClient.clear()
|
||||
|
||||
function Thrower(): never {
|
||||
throw new Promise(() => {
|
||||
// Never resolve
|
||||
})
|
||||
}
|
||||
|
||||
React.startTransition(() => {
|
||||
rendered.rerender(
|
||||
<React.Suspense fallback="loading">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={newDehydratedState}>
|
||||
<Page queryKey={['string']} />
|
||||
<Page queryKey={['added']} />
|
||||
<Thrower />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>
|
||||
</React.Suspense>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
React.startTransition(() => {
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={initialDehydratedState}>
|
||||
<Page queryKey={['string']} />
|
||||
<Page queryKey={['added']} />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
// This query existed before the transition so it should stay the same
|
||||
expect(rendered.getByText('string')).toBeInTheDocument()
|
||||
expect(
|
||||
rendered.queryByText('should not change'),
|
||||
).not.toBeInTheDocument()
|
||||
// New query data should be available immediately because it was
|
||||
// hydrated in the previous transition, even though the new dehydrated
|
||||
// state did not contain it
|
||||
expect(rendered.getByText('added')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
// It should stay the same even after effects have had a chance to run
|
||||
expect(rendered.getByText('string')).toBeInTheDocument()
|
||||
expect(rendered.queryByText('should not change')).not.toBeInTheDocument()
|
||||
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('should hydrate queries to new cache if cache changes', async () => {
|
||||
const dehydratedState = JSON.parse(stringifiedState)
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['string'],
|
||||
queryFn: () => sleep(20).then(() => ['string']),
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('stringCached')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(21)
|
||||
expect(rendered.getByText('string')).toBeInTheDocument()
|
||||
const newClientQueryClient = new QueryClient()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={newClientQueryClient}>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(rendered.getByText('string')).toBeInTheDocument()
|
||||
|
||||
queryClient.clear()
|
||||
newClientQueryClient.clear()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not hydrate queries if state is null', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
|
||||
|
||||
function Page() {
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={null}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 1000 }).map(async (_, index) => {
|
||||
await vi.advanceTimersByTimeAsync(index)
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(0)
|
||||
}),
|
||||
)
|
||||
|
||||
hydrateSpy.mockRestore()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('should not hydrate queries if state is undefined', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
|
||||
|
||||
function Page() {
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={undefined}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(0)
|
||||
|
||||
hydrateSpy.mockRestore()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('should not hydrate queries if state is not an object', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
|
||||
|
||||
function Page() {
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={'invalid-state' as any}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(0)
|
||||
|
||||
hydrateSpy.mockRestore()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('should handle state without queries property gracefully', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
|
||||
|
||||
function Page() {
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={{} as any}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(0)
|
||||
|
||||
hydrateSpy.mockRestore()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
// https://github.com/TanStack/query/issues/8677
|
||||
it('should not infinite loop when hydrating promises that resolve to errors', async () => {
|
||||
const originalHydrate = coreModule.hydrate
|
||||
const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
|
||||
let hydrationCount = 0
|
||||
hydrateSpy.mockImplementation((...args: Parameters<typeof hydrate>) => {
|
||||
hydrationCount++
|
||||
// Arbitrary number
|
||||
if (hydrationCount > 10) {
|
||||
// This is a rough way to detect it. Calling hydrate multiple times with
|
||||
// the same data is usually fine, but in this case it indicates the
|
||||
// logic in HydrationBoundary is not working as expected.
|
||||
throw new Error('Too many hydrations detected')
|
||||
}
|
||||
return originalHydrate(...args)
|
||||
})
|
||||
|
||||
// For the bug to trigger, there needs to already be a query in the cache,
|
||||
// with a dataUpdatedAt earlier than the dehydratedAt of the next query
|
||||
const clientQueryClient = new QueryClient()
|
||||
void clientQueryClient
|
||||
.query({
|
||||
queryKey: ['promise'],
|
||||
queryFn: () => sleep(20).then(() => 'existing'),
|
||||
})
|
||||
.catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
|
||||
const prefetchQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
dehydrate: {
|
||||
shouldDehydrateQuery: () => true,
|
||||
},
|
||||
},
|
||||
})
|
||||
void prefetchQueryClient
|
||||
.query({
|
||||
queryKey: ['promise'],
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => Promise.reject(new Error('Query failed'))),
|
||||
})
|
||||
.catch(noop)
|
||||
|
||||
const dehydratedState = dehydrate(prefetchQueryClient)
|
||||
|
||||
// Mimic what React/our synchronous thenable does for already rejected promises
|
||||
// @ts-expect-error
|
||||
dehydratedState.queries[0].promise.status = 'failure'
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['promise'],
|
||||
queryFn: () => sleep(20).then(() => ['new']),
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={clientQueryClient}>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('existing')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(21)
|
||||
expect(rendered.getByText('new')).toBeInTheDocument()
|
||||
|
||||
hydrateSpy.mockRestore()
|
||||
prefetchQueryClient.clear()
|
||||
clientQueryClient.clear()
|
||||
})
|
||||
|
||||
it('should not refetch when query has enabled set to false', async () => {
|
||||
const queryFn = vi.fn()
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['string'],
|
||||
queryFn,
|
||||
enabled: false,
|
||||
})
|
||||
return <div>{JSON.stringify(data)}</div>
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={JSON.parse(stringifiedState)}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('["stringCached"]')).toBeInTheDocument()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(queryFn).toHaveBeenCalledTimes(0)
|
||||
expect(rendered.getByText('["stringCached"]')).toBeInTheDocument()
|
||||
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('should not refetch when query has staleTime set to Infinity', async () => {
|
||||
const queryFn = vi.fn()
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['string'],
|
||||
queryFn,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
return <div>{JSON.stringify(data)}</div>
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={JSON.parse(stringifiedState)}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('["stringCached"]')).toBeInTheDocument()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(queryFn).toHaveBeenCalledTimes(0)
|
||||
expect(rendered.getByText('["stringCached"]')).toBeInTheDocument()
|
||||
|
||||
queryClient.clear()
|
||||
})
|
||||
})
|
||||
164
packages/react-query/src/__tests__/QueryClientProvider.test.tsx
Normal file
164
packages/react-query/src/__tests__/QueryClientProvider.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '..'
|
||||
|
||||
describe('QueryClientProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sets a specific cache for all queries to use', async () => {
|
||||
const key = queryKey()
|
||||
|
||||
const queryCache = new QueryCache()
|
||||
const queryClient = new QueryClient({ queryCache })
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => sleep(10).then(() => 'test'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('test')).toBeInTheDocument()
|
||||
|
||||
expect(queryCache.find({ queryKey: key })?.state.data).toBe('test')
|
||||
})
|
||||
|
||||
it('allows multiple caches to be partitioned', async () => {
|
||||
const key1 = queryKey()
|
||||
const key2 = queryKey()
|
||||
|
||||
const queryCache1 = new QueryCache()
|
||||
const queryCache2 = new QueryCache()
|
||||
|
||||
const queryClient1 = new QueryClient({ queryCache: queryCache1 })
|
||||
const queryClient2 = new QueryClient({ queryCache: queryCache2 })
|
||||
|
||||
function Page1() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key1,
|
||||
queryFn: () => sleep(10).then(() => 'test1'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function Page2() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key2,
|
||||
queryFn: () => sleep(10).then(() => 'test2'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<>
|
||||
<QueryClientProvider client={queryClient1}>
|
||||
<Page1 />
|
||||
</QueryClientProvider>
|
||||
<QueryClientProvider client={queryClient2}>
|
||||
<Page2 />
|
||||
</QueryClientProvider>
|
||||
</>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('test1')).toBeInTheDocument()
|
||||
expect(rendered.getByText('test2')).toBeInTheDocument()
|
||||
|
||||
expect(queryCache1.find({ queryKey: key1 })?.state.data).toBe('test1')
|
||||
expect(queryCache1.find({ queryKey: key2 })).toBeUndefined()
|
||||
expect(queryCache2.find({ queryKey: key1 })).toBeUndefined()
|
||||
expect(queryCache2.find({ queryKey: key2 })?.state.data).toBe('test2')
|
||||
})
|
||||
|
||||
it("uses defaultOptions for queries when they don't provide their own config", async () => {
|
||||
const key = queryKey()
|
||||
|
||||
const queryCache = new QueryCache()
|
||||
const queryClient = new QueryClient({
|
||||
queryCache,
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: Infinity,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => sleep(10).then(() => 'test'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('test')).toBeInTheDocument()
|
||||
|
||||
expect(queryCache.find({ queryKey: key })?.options.gcTime).toBe(Infinity)
|
||||
})
|
||||
|
||||
describe('useQueryClient', () => {
|
||||
it('should throw an error if no query client has been set', () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
function Page() {
|
||||
useQueryClient()
|
||||
return null
|
||||
}
|
||||
|
||||
expect(() => render(<Page />)).toThrow(
|
||||
'No QueryClient set, use QueryClientProvider to set one',
|
||||
)
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,947 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent } from '@testing-library/react'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import * as React from 'react'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
QueryErrorResetBoundary,
|
||||
useQueries,
|
||||
useQuery,
|
||||
useSuspenseQueries,
|
||||
useSuspenseQuery,
|
||||
} from '..'
|
||||
import { renderWithClient } from './utils'
|
||||
|
||||
describe('QueryErrorResetBoundary', () => {
|
||||
let queryCache: QueryCache
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryCache = new QueryCache()
|
||||
queryClient = new QueryClient({ queryCache })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useQuery', () => {
|
||||
it('should retry fetch if the reset error boundary has been reset', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not throw error if query is disabled', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const { data, status } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
enabled: !succeed,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>status: {status}</div>
|
||||
<div>{data}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('status: error')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not throw error if query is disabled, and refetch if query becomes enabled again', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const [enabled, setEnabled] = React.useState(false)
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
enabled,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
setEnabled(true)
|
||||
}, [])
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should throw error if query is disabled and manually refetch', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
const { data, refetch, status, fetchStatus } = useQuery<string>({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => Promise.reject(new Error('Error'))),
|
||||
retry: false,
|
||||
enabled: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => refetch()}>refetch</button>
|
||||
<div>
|
||||
status: {status}, fetchStatus: {fetchStatus}
|
||||
</div>
|
||||
<div>{data}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
expect(
|
||||
rendered.getByText('status: pending, fetchStatus: idle'),
|
||||
).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(
|
||||
rendered.getByText('status: pending, fetchStatus: idle'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /refetch/i }))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not retry fetch if the reset error boundary has not been reset', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{() => (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should retry fetch if the reset error boundary has been reset and the query contains data from a previous fetch', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
initialData: 'initial',
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('initial')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not retry fetch if the reset error boundary has not been reset after a previous reset', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
let shouldReset = false
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
if (shouldReset) {
|
||||
reset()
|
||||
}
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = false
|
||||
shouldReset = true
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
shouldReset = false
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
shouldReset = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should throw again on error after the reset error boundary has been reset', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
let fetchCount = 0
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery<string>({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
fetchCount++
|
||||
throw new Error('Error')
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
|
||||
expect(fetchCount).toBe(3)
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should never render the component while the query is in error state', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
let fetchCount = 0
|
||||
let renders = 0
|
||||
|
||||
function Page() {
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
fetchCount++
|
||||
if (fetchCount > 2) return 'data'
|
||||
throw new Error('Error')
|
||||
}),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
renders++
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<React.Suspense fallback={<div>loading</div>}>
|
||||
<Page />
|
||||
</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
expect(fetchCount).toBe(3)
|
||||
expect(renders).toBe(1)
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should render children', () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<span>page</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
<Page />
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
expect(rendered.queryByText('page')).not.toBeNull()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should show error boundary when using tracked queries even though we do not track the error field', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should refetch after error when staleTime is Infinity and previous data exists (#9728)', async () => {
|
||||
const key = queryKey()
|
||||
const queryFn = vi.fn()
|
||||
let count = 0
|
||||
|
||||
queryFn.mockImplementation(async () => {
|
||||
await sleep(10)
|
||||
count++
|
||||
if (count === 2) {
|
||||
throw new Error('Error ' + count)
|
||||
}
|
||||
return 'Success ' + count
|
||||
})
|
||||
|
||||
function Page() {
|
||||
const [_, forceUpdate] = React.useState(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
forceUpdate(1)
|
||||
}, [])
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn,
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Data: {data}</div>
|
||||
<button onClick={() => refetch()}>Refetch</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<React.StrictMode>
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>Status: error</div>
|
||||
<button onClick={resetErrorBoundary}>Retry</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
// 1. First mount -> fetching -> Success
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('Data: Success 1')).toBeInTheDocument()
|
||||
expect(queryFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// 2. Click Refetch -> Triggers fetch -> Fails (Error 2) -> ErrorBoundary
|
||||
fireEvent.click(rendered.getByText('Refetch'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('Status: error')).toBeInTheDocument()
|
||||
expect(queryFn).toHaveBeenCalledTimes(2)
|
||||
|
||||
// 3. Click Retry -> Remounts
|
||||
// Because staleTime is Infinity and we have Data from (1),
|
||||
// AND we are in Error state.
|
||||
fireEvent.click(rendered.getByText('Retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('Data: Success 3')).toBeInTheDocument()
|
||||
expect(queryFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useQueries', () => {
|
||||
it('should retry fetch if the reset error boundary has been reset', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const [{ data }] = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
retryOnMount: () => true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('with suspense should retry fetch if the reset error boundary has been reset', async () => {
|
||||
const key = queryKey()
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const [{ data }] = useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
retryOnMount: () => true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<React.Suspense fallback="loading">
|
||||
<Page />
|
||||
</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
PERSISTER_KEY_PREFIX,
|
||||
experimental_createQueryPersister,
|
||||
} from '@tanstack/query-persist-client-core'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { QueryCache, QueryClient, hashKey, useQuery } from '..'
|
||||
import { renderWithClient } from './utils'
|
||||
|
||||
describe('fine grained persister', () => {
|
||||
let queryCache: QueryCache
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryCache = new QueryCache()
|
||||
queryClient = new QueryClient({ queryCache })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should restore query state from persister and not refetch', async () => {
|
||||
const key = queryKey()
|
||||
const hash = hashKey(key)
|
||||
const spy = vi.fn(() => Promise.resolve('Works from queryFn'))
|
||||
|
||||
const mapStorage = new Map()
|
||||
const storage = {
|
||||
getItem: (itemKey: string) => Promise.resolve(mapStorage.get(itemKey)),
|
||||
setItem: (itemKey: string, value: unknown) => {
|
||||
mapStorage.set(itemKey, value)
|
||||
return Promise.resolve()
|
||||
},
|
||||
removeItem: (itemKey: string) => {
|
||||
mapStorage.delete(itemKey)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
|
||||
await storage.setItem(
|
||||
`${PERSISTER_KEY_PREFIX}-${hash}`,
|
||||
JSON.stringify({
|
||||
buster: '',
|
||||
queryHash: hash,
|
||||
queryKey: key,
|
||||
state: {
|
||||
dataUpdatedAt: Date.now(),
|
||||
data: 'Works from persister',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
function Test() {
|
||||
const [_, setRef] = React.useState<HTMLDivElement | null>()
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: spy,
|
||||
persister: experimental_createQueryPersister({
|
||||
storage,
|
||||
}).persisterFn,
|
||||
staleTime: 5000,
|
||||
})
|
||||
|
||||
return <div ref={(value) => setRef(value)}>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Test />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rendered.getByText('Works from persister')).toBeInTheDocument()
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should restore query state from persister and refetch', async () => {
|
||||
const key = queryKey()
|
||||
const hash = hashKey(key)
|
||||
const spy = vi.fn(() => sleep(5).then(() => 'Works from queryFn'))
|
||||
|
||||
const mapStorage = new Map()
|
||||
const storage = {
|
||||
getItem: (itemKey: string) => Promise.resolve(mapStorage.get(itemKey)),
|
||||
setItem: (itemKey: string, value: unknown) => {
|
||||
mapStorage.set(itemKey, value)
|
||||
return Promise.resolve()
|
||||
},
|
||||
removeItem: (itemKey: string) => {
|
||||
mapStorage.delete(itemKey)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
|
||||
await storage.setItem(
|
||||
`${PERSISTER_KEY_PREFIX}-${hash}`,
|
||||
JSON.stringify({
|
||||
buster: '',
|
||||
queryHash: hash,
|
||||
queryKey: key,
|
||||
state: {
|
||||
dataUpdatedAt: Date.now(),
|
||||
data: 'Works from persister',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
function Test() {
|
||||
const [_, setRef] = React.useState<HTMLDivElement | null>()
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: spy,
|
||||
persister: experimental_createQueryPersister({
|
||||
storage,
|
||||
}).persisterFn,
|
||||
})
|
||||
|
||||
return <div ref={(value) => setRef(value)}>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Test />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rendered.getByText('Works from persister')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(6)
|
||||
expect(rendered.getByText('Works from queryFn')).toBeInTheDocument()
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should store query state to persister after fetch', async () => {
|
||||
const key = queryKey()
|
||||
const hash = hashKey(key)
|
||||
const spy = vi.fn(() => Promise.resolve('Works from queryFn'))
|
||||
|
||||
const mapStorage = new Map()
|
||||
const storage = {
|
||||
getItem: (itemKey: string) => Promise.resolve(mapStorage.get(itemKey)),
|
||||
setItem: (itemKey: string, value: unknown) => {
|
||||
mapStorage.set(itemKey, value)
|
||||
return Promise.resolve()
|
||||
},
|
||||
removeItem: (itemKey: string) => {
|
||||
mapStorage.delete(itemKey)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
|
||||
function Test() {
|
||||
const [_, setRef] = React.useState<HTMLDivElement | null>()
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: spy,
|
||||
persister: experimental_createQueryPersister({
|
||||
storage,
|
||||
}).persisterFn,
|
||||
})
|
||||
|
||||
return <div ref={(value) => setRef(value)}>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Test />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rendered.getByText('Works from queryFn')).toBeInTheDocument()
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const storedItem = await storage.getItem(`${PERSISTER_KEY_PREFIX}-${hash}`)
|
||||
expect(JSON.parse(storedItem)).toMatchObject({
|
||||
state: {
|
||||
data: 'Works from queryFn',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,364 @@
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
import { QueryClient, dataTagSymbol, skipToken } from '@tanstack/query-core'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { infiniteQueryOptions } from '../infiniteQueryOptions'
|
||||
import { useInfiniteQuery } from '../useInfiniteQuery'
|
||||
import { useSuspenseInfiniteQuery } from '../useSuspenseInfiniteQuery'
|
||||
import { useQuery } from '../useQuery'
|
||||
import type {
|
||||
DataTag,
|
||||
InfiniteData,
|
||||
InitialDataFunction,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
// Regression test for exported infiniteQueryOptions inference under declaration emit.
|
||||
// TypeScript should be able to name the return type without expanding the
|
||||
// internal data tag symbols into the consumer's .d.ts output.
|
||||
export const exportedInfiniteQueryOptions = infiniteQueryOptions({
|
||||
queryKey: ['invalid'],
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
describe('infiniteQueryOptions', () => {
|
||||
it('should not allow excess properties', () => {
|
||||
assertType(
|
||||
infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('data'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
// @ts-expect-error this is a good error, because stallTime does not exist!
|
||||
stallTime: 1000,
|
||||
}),
|
||||
)
|
||||
})
|
||||
it('should infer types for callbacks', () => {
|
||||
infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('data'),
|
||||
staleTime: 1000,
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
|
||||
},
|
||||
})
|
||||
})
|
||||
it('should work when passed to useInfiniteQuery', () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
const { data } = useInfiniteQuery(options)
|
||||
|
||||
// known issue: type of pageParams is unknown when returned from useInfiniteQuery
|
||||
expectTypeOf(data).toEqualTypeOf<
|
||||
InfiniteData<string, unknown> | undefined
|
||||
>()
|
||||
})
|
||||
it('should work when passed to useInfiniteQuery with select', () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
select: (data) => data.pages,
|
||||
})
|
||||
|
||||
const { data } = useInfiniteQuery(options)
|
||||
|
||||
// known issue: type of pageParams is unknown when returned from useInfiniteQuery
|
||||
expectTypeOf(data).toEqualTypeOf<Array<string> | undefined>()
|
||||
})
|
||||
it('should work when passed to useSuspenseInfiniteQuery', () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
const { data } = useSuspenseInfiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, unknown>>()
|
||||
})
|
||||
it('should work when passed to useSuspenseInfiniteQuery with select', () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
select: (data) => data.pages,
|
||||
})
|
||||
|
||||
const { data } = useSuspenseInfiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<Array<string>>()
|
||||
})
|
||||
it('should work when passed to infiniteQuery', async () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
const data = await new QueryClient().infiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
|
||||
})
|
||||
it('should work when passed to infiniteQuery with select', async () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
select: (data) => data.pages,
|
||||
})
|
||||
|
||||
const data = await new QueryClient().infiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<Array<string>>()
|
||||
})
|
||||
it('should work when passed to infiniteQuery with enabled: false', async () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const data = await new QueryClient().infiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
|
||||
})
|
||||
it('should work when passed to infiniteQuery with skipToken', async () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: skipToken,
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
const data = await new QueryClient().infiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<unknown, number>>()
|
||||
})
|
||||
it('should work when passed to fetchInfiniteQuery', async () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
// grandfathered direct test
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const data = await new QueryClient().fetchInfiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
|
||||
})
|
||||
it('should ignore select when passed to fetchInfiniteQuery', async () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
select: (data) => data.pages,
|
||||
})
|
||||
|
||||
// grandfathered direct test
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const data = await new QueryClient().fetchInfiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
|
||||
})
|
||||
it('should tag the queryKey with the result type of the QueryFn', () => {
|
||||
const { queryKey: tagged } = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<InfiniteData<string>>()
|
||||
})
|
||||
it('should tag the queryKey even if no promise is returned', () => {
|
||||
const { queryKey: tagged } = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 'string',
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<InfiniteData<string>>()
|
||||
})
|
||||
it('should tag the queryKey with the result type of the QueryFn if select is used', () => {
|
||||
const { queryKey: tagged } = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
select: (data) => data.pages,
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<InfiniteData<string>>()
|
||||
})
|
||||
it('should return the proper type when passed to getQueryData', () => {
|
||||
const { queryKey: tagged } = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const data = queryClient.getQueryData(tagged)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<
|
||||
InfiniteData<string, unknown> | undefined
|
||||
>()
|
||||
})
|
||||
it('should properly type when passed to setQueryData', () => {
|
||||
const { queryKey: tagged } = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const data = queryClient.setQueryData(tagged, (prev) => {
|
||||
expectTypeOf(prev).toEqualTypeOf<
|
||||
InfiniteData<string, unknown> | undefined
|
||||
>()
|
||||
return prev
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<
|
||||
InfiniteData<string, unknown> | undefined
|
||||
>()
|
||||
})
|
||||
it('should throw a type error when using queryFn with skipToken in a suspense query', () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn:
|
||||
Math.random() > 0.5 ? skipToken : () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
// @ts-expect-error TS2345
|
||||
const { data } = useSuspenseInfiniteQuery(options)
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, unknown>>()
|
||||
})
|
||||
|
||||
it('should not be allowed to be passed to non-infinite query functions', () => {
|
||||
const queryClient = new QueryClient()
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
assertType(
|
||||
// @ts-expect-error cannot pass infinite options to non-infinite query functions
|
||||
useQuery(options),
|
||||
)
|
||||
assertType(
|
||||
// @ts-expect-error cannot pass infinite options to non-infinite query functions
|
||||
queryClient.query(options),
|
||||
)
|
||||
|
||||
// deprecated methods to be removed in v6
|
||||
assertType(
|
||||
// @ts-expect-error cannot pass infinite options to non-infinite query functions
|
||||
// eslint-disable-next-line no-restricted-syntax -- grandfathered direct test
|
||||
queryClient.ensureQueryData(options),
|
||||
)
|
||||
assertType(
|
||||
// @ts-expect-error cannot pass infinite options to non-infinite query functions
|
||||
// eslint-disable-next-line no-restricted-syntax -- grandfathered direct test
|
||||
queryClient.fetchQuery(options),
|
||||
)
|
||||
assertType(
|
||||
// @ts-expect-error cannot pass infinite options to non-infinite query functions
|
||||
// eslint-disable-next-line no-restricted-syntax -- grandfathered direct test
|
||||
queryClient.prefetchQuery(options),
|
||||
)
|
||||
})
|
||||
|
||||
it('allow optional initialData function', () => {
|
||||
const initialData: { example: boolean } | undefined = { example: true }
|
||||
const queryOptions = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => initialData,
|
||||
initialData: initialData
|
||||
? () => ({ pages: [initialData], pageParams: [] })
|
||||
: undefined,
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
expectTypeOf(queryOptions.initialData).toExtend<
|
||||
| InitialDataFunction<InfiniteData<{ example: boolean }, number>>
|
||||
| InfiniteData<{ example: boolean }, number>
|
||||
| undefined
|
||||
>()
|
||||
})
|
||||
|
||||
it('allow optional initialData object', () => {
|
||||
const initialData: { example: boolean } | undefined = { example: true }
|
||||
const queryOptions = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => initialData,
|
||||
initialData: initialData
|
||||
? { pages: [initialData], pageParams: [] }
|
||||
: undefined,
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
expectTypeOf(queryOptions.initialData).toExtend<
|
||||
| InitialDataFunction<InfiniteData<{ example: boolean }, number>>
|
||||
| InfiniteData<{ example: boolean }, number>
|
||||
| undefined
|
||||
>()
|
||||
})
|
||||
|
||||
it('should return a custom query key type', () => {
|
||||
type MyQueryKey = [Array<string>, { type: 'foo' }]
|
||||
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: [['key'], { type: 'foo' }] as MyQueryKey,
|
||||
queryFn: () => Promise.resolve(1),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
expectTypeOf(options.queryKey).toEqualTypeOf<
|
||||
DataTag<MyQueryKey, InfiniteData<number>, Error>
|
||||
>()
|
||||
})
|
||||
|
||||
it('should return a custom query key type with datatag', () => {
|
||||
type MyQueryKey = DataTag<
|
||||
[Array<string>, { type: 'foo' }],
|
||||
number,
|
||||
Error & { myMessage: string }
|
||||
>
|
||||
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: [['key'], { type: 'foo' }] as MyQueryKey,
|
||||
queryFn: () => Promise.resolve(1),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
expectTypeOf(options.queryKey).toEqualTypeOf<
|
||||
DataTag<MyQueryKey, InfiniteData<number>, Error & { myMessage: string }>
|
||||
>()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
|
||||
import { infiniteQueryOptions } from '../infiniteQueryOptions'
|
||||
import type { UseInfiniteQueryOptions } from '../types'
|
||||
|
||||
describe('infiniteQueryOptions', () => {
|
||||
it('should return the object received as a parameter without any modification.', () => {
|
||||
const object: UseInfiniteQueryOptions = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
getNextPageParam: () => null,
|
||||
initialPageParam: null,
|
||||
}
|
||||
|
||||
expect(infiniteQueryOptions(object)).toBe(object)
|
||||
})
|
||||
})
|
||||
218
packages/react-query/src/__tests__/mutationOptions.test-d.tsx
Normal file
218
packages/react-query/src/__tests__/mutationOptions.test-d.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
import { QueryClient } from '@tanstack/query-core'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { useIsMutating, useMutation, useMutationState } from '..'
|
||||
import { mutationOptions } from '../mutationOptions'
|
||||
import type {
|
||||
DefaultError,
|
||||
MutationFunctionContext,
|
||||
MutationState,
|
||||
WithRequired,
|
||||
} from '@tanstack/query-core'
|
||||
import type { UseMutationOptions, UseMutationResult } from '../types'
|
||||
|
||||
describe('mutationOptions', () => {
|
||||
it('should not allow excess properties', () => {
|
||||
mutationOptions({
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error this is a good error, because onMutates does not exist!
|
||||
onMutates: 1000,
|
||||
mutationKey: queryKey(),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer types for callbacks', () => {
|
||||
mutationOptions({
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
mutationKey: queryKey(),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer types for onError callback', () => {
|
||||
mutationOptions({
|
||||
mutationFn: () => {
|
||||
throw new Error('fail')
|
||||
},
|
||||
mutationKey: queryKey(),
|
||||
onError: (error) => {
|
||||
expectTypeOf(error).toEqualTypeOf<DefaultError>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer types for variables', () => {
|
||||
mutationOptions<number, DefaultError, { id: string }>({
|
||||
mutationFn: (vars) => {
|
||||
expectTypeOf(vars).toEqualTypeOf<{ id: string }>()
|
||||
return Promise.resolve(5)
|
||||
},
|
||||
mutationKey: queryKey(),
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer result type correctly', () => {
|
||||
mutationOptions<number, DefaultError, void, { name: string }>({
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
mutationKey: queryKey(),
|
||||
onMutate: () => {
|
||||
return { name: 'onMutateResult' }
|
||||
},
|
||||
onSuccess: (_data, _variables, onMutateResult) => {
|
||||
expectTypeOf(onMutateResult).toEqualTypeOf<{ name: string }>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer context type correctly', () => {
|
||||
mutationOptions<number>({
|
||||
mutationFn: (_variables, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
return Promise.resolve(5)
|
||||
},
|
||||
mutationKey: queryKey(),
|
||||
onMutate: (_variables, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onSuccess: (_data, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onError: (_error, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onSettled: (_data, _error, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should error if mutationFn return type mismatches TData', () => {
|
||||
assertType(
|
||||
mutationOptions<number>({
|
||||
// @ts-expect-error this is a good error, because return type is string, not number
|
||||
mutationFn: async () => Promise.resolve('wrong return'),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow mutationKey to be omitted', () => {
|
||||
return mutationOptions({
|
||||
mutationFn: () => Promise.resolve(123),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer all types when not explicitly provided', () => {
|
||||
expectTypeOf(
|
||||
mutationOptions({
|
||||
mutationFn: (id: string) => Promise.resolve(id.length),
|
||||
mutationKey: queryKey(),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
},
|
||||
}),
|
||||
).toEqualTypeOf<
|
||||
WithRequired<
|
||||
UseMutationOptions<number, DefaultError, string>,
|
||||
'mutationKey'
|
||||
>
|
||||
>()
|
||||
expectTypeOf(
|
||||
mutationOptions({
|
||||
mutationFn: (id: string) => Promise.resolve(id.length),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
},
|
||||
}),
|
||||
).toEqualTypeOf<
|
||||
Omit<UseMutationOptions<number, DefaultError, string>, 'mutationKey'>
|
||||
>()
|
||||
})
|
||||
|
||||
it('should infer types when used with useMutation', () => {
|
||||
const mutation = useMutation(
|
||||
mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<string>()
|
||||
},
|
||||
}),
|
||||
)
|
||||
expectTypeOf(mutation).toEqualTypeOf<
|
||||
UseMutationResult<string, DefaultError, void, unknown>
|
||||
>()
|
||||
|
||||
useMutation(
|
||||
// should allow when used with useMutation without mutationKey
|
||||
mutationOptions({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<string>()
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should infer types when used with useIsMutating', () => {
|
||||
const isMutating = useIsMutating(
|
||||
mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
expectTypeOf(isMutating).toEqualTypeOf<number>()
|
||||
|
||||
useIsMutating(
|
||||
// @ts-expect-error filters should have mutationKey
|
||||
mutationOptions({
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should infer types when used with queryClient.isMutating', () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const isMutating = queryClient.isMutating(
|
||||
mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
expectTypeOf(isMutating).toEqualTypeOf<number>()
|
||||
|
||||
queryClient.isMutating(
|
||||
// @ts-expect-error filters should have mutationKey
|
||||
mutationOptions({
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should infer types when used with useMutationState', () => {
|
||||
const mutationState = useMutationState({
|
||||
filters: mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
}),
|
||||
})
|
||||
expectTypeOf(mutationState).toEqualTypeOf<
|
||||
Array<MutationState<unknown, Error, unknown, unknown>>
|
||||
>()
|
||||
|
||||
useMutationState({
|
||||
// @ts-expect-error filters should have mutationKey
|
||||
filters: mutationOptions({
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
526
packages/react-query/src/__tests__/mutationOptions.test.tsx
Normal file
526
packages/react-query/src/__tests__/mutationOptions.test.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { QueryClient } from '@tanstack/query-core'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import { mutationOptions } from '../mutationOptions'
|
||||
import { useIsMutating, useMutation, useMutationState } from '..'
|
||||
import { renderWithClient } from './utils'
|
||||
import type { MutationState } from '@tanstack/query-core'
|
||||
import type { UseMutationOptions } from '../types'
|
||||
|
||||
describe('mutationOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should return the object received as a parameter without any modification (with mutationKey in mutationOptions)', () => {
|
||||
const object: UseMutationOptions = {
|
||||
mutationKey: ['key'],
|
||||
mutationFn: () => sleep(10).then(() => 5),
|
||||
} as const
|
||||
|
||||
expect(mutationOptions(object)).toBe(object)
|
||||
})
|
||||
|
||||
it('should return the object received as a parameter without any modification (without mutationKey in mutationOptions)', () => {
|
||||
const object: UseMutationOptions = {
|
||||
mutationFn: () => sleep(10).then(() => 5),
|
||||
} as const
|
||||
|
||||
expect(mutationOptions(object)).toBe(object)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useIsMutating (with mutationKey in mutationOptions)', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts = mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => sleep(50).then(() => 'data'),
|
||||
})
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating()
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Mutation() {
|
||||
const { mutate } = useMutation(mutationOpts)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate()}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<IsMutating />
|
||||
<Mutation />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(51)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useIsMutating (without mutationKey in mutationOptions)', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts = mutationOptions({
|
||||
mutationFn: () => sleep(50).then(() => 'data'),
|
||||
})
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating()
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Mutation() {
|
||||
const { mutate } = useMutation(mutationOpts)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate()}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<IsMutating />
|
||||
<Mutation />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(51)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useIsMutating', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts1 = mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => sleep(50).then(() => 'data1'),
|
||||
})
|
||||
const mutationOpts2 = mutationOptions({
|
||||
mutationFn: () => sleep(50).then(() => 'data2'),
|
||||
})
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating()
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Mutation() {
|
||||
const { mutate: mutate1 } = useMutation(mutationOpts1)
|
||||
const { mutate: mutate2 } = useMutation(mutationOpts2)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<IsMutating />
|
||||
<Mutation />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(2)
|
||||
await vi.advanceTimersByTimeAsync(51)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useIsMutating (filter mutationOpts1.mutationKey)', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts1 = mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => sleep(50).then(() => 'data1'),
|
||||
})
|
||||
const mutationOpts2 = mutationOptions({
|
||||
mutationFn: () => sleep(50).then(() => 'data2'),
|
||||
})
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating({
|
||||
mutationKey: mutationOpts1.mutationKey,
|
||||
})
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Mutation() {
|
||||
const { mutate: mutate1 } = useMutation(mutationOpts1)
|
||||
const { mutate: mutate2 } = useMutation(mutationOpts2)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<IsMutating />
|
||||
<Mutation />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(51)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with queryClient.isMutating (with mutationKey in mutationOptions)', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts = mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => sleep(500).then(() => 'data'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const isMutating = queryClient.isMutating(mutationOpts)
|
||||
const { mutate } = useMutation(mutationOpts)
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate()}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with queryClient.isMutating (without mutationKey in mutationOptions)', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts = mutationOptions({
|
||||
mutationFn: () => sleep(500).then(() => 'data'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const isMutating = queryClient.isMutating()
|
||||
const { mutate } = useMutation(mutationOpts)
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate()}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with queryClient.isMutating', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts1 = mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => sleep(500).then(() => 'data1'),
|
||||
})
|
||||
const mutationOpts2 = mutationOptions({
|
||||
mutationFn: () => sleep(500).then(() => 'data2'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const isMutating = queryClient.isMutating()
|
||||
const { mutate: mutate1 } = useMutation(mutationOpts1)
|
||||
const { mutate: mutate2 } = useMutation(mutationOpts2)
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(2)
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with queryClient.isMutating (filter mutationOpt1.mutationKey)', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts1 = mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => sleep(500).then(() => 'data1'),
|
||||
})
|
||||
const mutationOpts2 = mutationOptions({
|
||||
mutationFn: () => sleep(500).then(() => 'data2'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const isMutating = queryClient.isMutating({
|
||||
mutationKey: mutationOpts1.mutationKey,
|
||||
})
|
||||
const { mutate: mutate1 } = useMutation(mutationOpts1)
|
||||
const { mutate: mutate2 } = useMutation(mutationOpts2)
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useMutationState (with mutationKey in mutationOptions)', async () => {
|
||||
const mutationStateArray: Array<
|
||||
MutationState<unknown, Error, unknown, unknown>
|
||||
> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts = mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => sleep(10).then(() => 'data'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const { mutate } = useMutation(mutationOpts)
|
||||
const data = useMutationState({
|
||||
filters: { mutationKey: mutationOpts.mutationKey, status: 'success' },
|
||||
})
|
||||
|
||||
mutationStateArray.push(...data)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate()}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
expect(mutationStateArray.length).toEqual(0)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(mutationStateArray.length).toEqual(1)
|
||||
expect(mutationStateArray[0]?.data).toEqual('data')
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useMutationState (without mutationKey in mutationOptions)', async () => {
|
||||
const mutationStateArray: Array<
|
||||
MutationState<unknown, Error, unknown, unknown>
|
||||
> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts = mutationOptions({
|
||||
mutationFn: () => sleep(10).then(() => 'data'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const { mutate } = useMutation(mutationOpts)
|
||||
const data = useMutationState({
|
||||
filters: { status: 'success' },
|
||||
})
|
||||
|
||||
mutationStateArray.push(...data)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate()}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
expect(mutationStateArray.length).toEqual(0)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(mutationStateArray.length).toEqual(1)
|
||||
expect(mutationStateArray[0]?.data).toEqual('data')
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useMutationState', async () => {
|
||||
const mutationStateArray: Array<
|
||||
MutationState<unknown, Error, unknown, unknown>
|
||||
> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts1 = mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => sleep(10).then(() => 'data1'),
|
||||
})
|
||||
const mutationOpts2 = mutationOptions({
|
||||
mutationFn: () => sleep(10).then(() => 'data2'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const { mutate: mutate1 } = useMutation(mutationOpts1)
|
||||
const { mutate: mutate2 } = useMutation(mutationOpts2)
|
||||
const data = useMutationState({
|
||||
filters: { status: 'success' },
|
||||
})
|
||||
|
||||
mutationStateArray.push(...data)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
expect(mutationStateArray.length).toEqual(0)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(mutationStateArray.length).toEqual(2)
|
||||
expect(mutationStateArray[0]?.data).toEqual('data1')
|
||||
expect(mutationStateArray[1]?.data).toEqual('data2')
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useMutationState (filter mutationOpt1.mutationKey)', async () => {
|
||||
const mutationStateArray: Array<
|
||||
MutationState<unknown, Error, unknown, unknown>
|
||||
> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts1 = mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => sleep(10).then(() => 'data1'),
|
||||
})
|
||||
const mutationOpts2 = mutationOptions({
|
||||
mutationFn: () => sleep(10).then(() => 'data2'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const { mutate: mutate1 } = useMutation(mutationOpts1)
|
||||
const { mutate: mutate2 } = useMutation(mutationOpts2)
|
||||
const data = useMutationState({
|
||||
filters: { mutationKey: mutationOpts1.mutationKey, status: 'success' },
|
||||
})
|
||||
|
||||
mutationStateArray.push(...data)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
expect(mutationStateArray.length).toEqual(0)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(mutationStateArray.length).toEqual(1)
|
||||
expect(mutationStateArray[0]?.data).toEqual('data1')
|
||||
})
|
||||
})
|
||||
383
packages/react-query/src/__tests__/queryOptions.test-d.tsx
Normal file
383
packages/react-query/src/__tests__/queryOptions.test-d.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
import {
|
||||
QueriesObserver,
|
||||
QueryClient,
|
||||
dataTagSymbol,
|
||||
skipToken,
|
||||
} from '@tanstack/query-core'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { queryOptions } from '../queryOptions'
|
||||
import { useQuery } from '../useQuery'
|
||||
import { useQueries } from '../useQueries'
|
||||
import { useSuspenseQuery } from '../useSuspenseQuery'
|
||||
import type { AnyUseQueryOptions } from '../types'
|
||||
import type {
|
||||
DataTag,
|
||||
InitialDataFunction,
|
||||
QueryObserverResult,
|
||||
QueryPersister,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
// Regression test for exported queryOptions inference under declaration emit.
|
||||
// TypeScript should be able to name the return type without expanding the
|
||||
// internal data tag symbols into the consumer's .d.ts output.
|
||||
export const exportedQueryOptions = queryOptions({
|
||||
queryKey: ['invalid'],
|
||||
})
|
||||
|
||||
describe('queryOptions', () => {
|
||||
it('should not allow excess properties', () => {
|
||||
assertType(
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error this is a good error, because stallTime does not exist!
|
||||
stallTime: 1000,
|
||||
}),
|
||||
)
|
||||
})
|
||||
it('should infer types for callbacks', () => {
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
staleTime: 1000,
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
},
|
||||
})
|
||||
})
|
||||
it('should work when passed to useQuery', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const { data } = useQuery(options)
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
it('should work when passed to useSuspenseQuery', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const { data } = useSuspenseQuery(options)
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should work when passed to query', async () => {
|
||||
const options = queryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const data = await new QueryClient().query(options)
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
|
||||
it('should work when passed to fetchQuery', async () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
// grandfathered direct test
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const data = await new QueryClient().fetchQuery(options)
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should work when passed to query with select', async () => {
|
||||
const options = queryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve(5),
|
||||
select: (data) => data.toString(),
|
||||
})
|
||||
|
||||
const data = await new QueryClient().query(options)
|
||||
expectTypeOf(data).toEqualTypeOf<string>()
|
||||
})
|
||||
it('should work when passed to query with enabled: false', async () => {
|
||||
const options = queryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve(5),
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const data = await new QueryClient().query(options)
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should work when passed to query with skipToken', async () => {
|
||||
const options = queryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: skipToken,
|
||||
})
|
||||
|
||||
const data = await new QueryClient().query(options)
|
||||
expectTypeOf(data).toEqualTypeOf<unknown>()
|
||||
})
|
||||
it('should ignore select when passed to fetchQuery', async () => {
|
||||
const options = queryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve(5),
|
||||
select: (data) => data.toString(),
|
||||
})
|
||||
|
||||
// grandfathered direct test
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const data = await new QueryClient().fetchQuery(options)
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should work when passed to useQueries', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const [{ data }] = useQueries({
|
||||
queries: [options],
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
it('should tag the queryKey with the result type of the QueryFn', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should tag the queryKey even if no promise is returned', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 5,
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should tag the queryKey with unknown if there is no queryFn', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<unknown>()
|
||||
})
|
||||
it('should tag the queryKey with the result type of the QueryFn if select is used', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
select: (data) => data.toString(),
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should return the proper type when passed to getQueryData', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const data = queryClient.getQueryData(tagged)
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
it('should return the proper type when passed to getQueryState', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const state = queryClient.getQueryState(tagged)
|
||||
expectTypeOf(state?.data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
it('should properly type updaterFn when passed to setQueryData', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const data = queryClient.setQueryData(tagged, (prev) => {
|
||||
expectTypeOf(prev).toEqualTypeOf<number | undefined>()
|
||||
return prev
|
||||
})
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
it('should properly type value when passed to setQueryData', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
// @ts-expect-error value should be a number
|
||||
queryClient.setQueryData(tagged, '5')
|
||||
// @ts-expect-error value should be a number
|
||||
queryClient.setQueryData(tagged, () => '5')
|
||||
|
||||
const data = queryClient.setQueryData(tagged, 5)
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
|
||||
it('should infer even if there is a conditional skipToken', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const data = queryClient.getQueryData(options.queryKey)
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
|
||||
it('should infer to unknown if we disable a query with just a skipToken', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: skipToken,
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const data = queryClient.getQueryData(options.queryKey)
|
||||
expectTypeOf(data).toEqualTypeOf<unknown>()
|
||||
})
|
||||
|
||||
it('should throw a type error when using queryFn with skipToken in a suspense query', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
})
|
||||
// @ts-expect-error TS2345
|
||||
const { data } = useSuspenseQuery(options)
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
|
||||
it('should return the proper type when passed to QueriesObserver', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const queriesObserver = new QueriesObserver(queryClient, [options])
|
||||
expectTypeOf(queriesObserver).toEqualTypeOf<
|
||||
QueriesObserver<Array<QueryObserverResult>>
|
||||
>()
|
||||
})
|
||||
|
||||
it('should allow undefined response in initialData', () => {
|
||||
assertType((id: string | null) =>
|
||||
queryOptions({
|
||||
queryKey: ['todo', id],
|
||||
queryFn: () =>
|
||||
Promise.resolve({
|
||||
id: '1',
|
||||
title: 'Do Laundry',
|
||||
}),
|
||||
initialData: () =>
|
||||
!id
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
title: 'Initial Data',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow optional initialData object', () => {
|
||||
const testFn = (id?: string) => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('something string'),
|
||||
initialData: id ? 'initial string' : undefined,
|
||||
})
|
||||
expectTypeOf(options.initialData).toExtend<
|
||||
InitialDataFunction<string> | string | undefined
|
||||
>()
|
||||
}
|
||||
testFn('id')
|
||||
testFn()
|
||||
})
|
||||
|
||||
it('should be passable to UseQueryOptions', () => {
|
||||
function somethingWithQueryOptions<TQueryOpts extends AnyUseQueryOptions>(
|
||||
options: TQueryOpts,
|
||||
) {
|
||||
return options.queryKey
|
||||
}
|
||||
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
})
|
||||
|
||||
assertType(somethingWithQueryOptions(options))
|
||||
})
|
||||
|
||||
it('should return a custom query key type', () => {
|
||||
type MyQueryKey = [Array<string>, { type: 'foo' }]
|
||||
|
||||
const options = queryOptions({
|
||||
queryKey: [['key'], { type: 'foo' }] as MyQueryKey,
|
||||
queryFn: () => Promise.resolve(1),
|
||||
})
|
||||
|
||||
expectTypeOf(options.queryKey).toEqualTypeOf<
|
||||
DataTag<MyQueryKey, number, Error>
|
||||
>()
|
||||
})
|
||||
|
||||
it('should return a custom query key type with datatag', () => {
|
||||
type MyQueryKey = DataTag<
|
||||
[Array<string>, { type: 'foo' }],
|
||||
number,
|
||||
Error & { myMessage: string }
|
||||
>
|
||||
|
||||
const options = queryOptions({
|
||||
queryKey: [['key'], { type: 'foo' }] as MyQueryKey,
|
||||
queryFn: () => Promise.resolve(1),
|
||||
})
|
||||
|
||||
expectTypeOf(options.queryKey).toEqualTypeOf<
|
||||
DataTag<MyQueryKey, number, Error & { myMessage: string }>
|
||||
>()
|
||||
})
|
||||
|
||||
it('should infer TQueryFnData from persister paired with a queryFn declaring a parameter (#7842)', () => {
|
||||
const persister = undefined as unknown as QueryPersister<string, any>
|
||||
|
||||
const options = queryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: (_context) => 'hello',
|
||||
persister,
|
||||
})
|
||||
|
||||
expectTypeOf(options.queryFn!).returns.toEqualTypeOf<
|
||||
string | Promise<string>
|
||||
>()
|
||||
})
|
||||
|
||||
it('should still error when persister and queryFn return types genuinely conflict', () => {
|
||||
const persister = undefined as unknown as QueryPersister<string, any>
|
||||
|
||||
assertType(
|
||||
queryOptions({
|
||||
queryKey: ['key'],
|
||||
// @ts-expect-error persister expects string, queryFn returns number
|
||||
queryFn: () => 42,
|
||||
persister,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
queryOptions({
|
||||
queryKey: ['key'],
|
||||
// @ts-expect-error persister expects string, queryFn with arg returns number
|
||||
queryFn: (_context) => 42,
|
||||
persister,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
15
packages/react-query/src/__tests__/queryOptions.test.tsx
Normal file
15
packages/react-query/src/__tests__/queryOptions.test.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { queryOptions } from '../queryOptions'
|
||||
import type { UseQueryOptions } from '../types'
|
||||
|
||||
describe('queryOptions', () => {
|
||||
it('should return the object received as a parameter without any modification.', () => {
|
||||
const object: UseQueryOptions = {
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve(5),
|
||||
} as const
|
||||
|
||||
expect(queryOptions(object)).toBe(object)
|
||||
})
|
||||
})
|
||||
278
packages/react-query/src/__tests__/ssr-hydration.test.tsx
Normal file
278
packages/react-query/src/__tests__/ssr-hydration.test.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { hydrateRoot } from 'react-dom/client'
|
||||
import { act } from 'react'
|
||||
import * as ReactDOMServer from 'react-dom/server'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
dehydrate,
|
||||
hydrate,
|
||||
noop,
|
||||
useQuery,
|
||||
} from '..'
|
||||
import { setIsServer } from './utils'
|
||||
|
||||
const ReactHydrate = (element: React.ReactElement, container: Element) => {
|
||||
let root: any
|
||||
act(() => {
|
||||
root = hydrateRoot(container, element)
|
||||
})
|
||||
return () => {
|
||||
root.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData<TData>(value: TData, ms?: number): Promise<TData> {
|
||||
await vi.advanceTimersByTimeAsync(ms || 1)
|
||||
return value
|
||||
}
|
||||
|
||||
function PrintStateComponent({ componentName, result }: any): any {
|
||||
return `${componentName} - status:${result.status} fetching:${result.isFetching} data:${result.data}`
|
||||
}
|
||||
|
||||
describe('Server side rendering with de/rehydration', () => {
|
||||
let previousIsReactActEnvironment: unknown
|
||||
beforeAll(() => {
|
||||
// @ts-expect-error we expect IS_REACT_ACT_ENVIRONMENT to exist
|
||||
previousIsReactActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
// @ts-expect-error we expect IS_REACT_ACT_ENVIRONMENT to exist
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should not mismatch on success', async () => {
|
||||
const consoleMock = vi.spyOn(console, 'error')
|
||||
consoleMock.mockImplementation(() => undefined)
|
||||
|
||||
const fetchDataSuccess = vi.fn<typeof fetchData>(fetchData)
|
||||
const key = queryKey()
|
||||
|
||||
// -- Shared part --
|
||||
function SuccessComponent() {
|
||||
const result = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => fetchDataSuccess('success!'),
|
||||
})
|
||||
return (
|
||||
<PrintStateComponent componentName="SuccessComponent" result={result} />
|
||||
)
|
||||
}
|
||||
|
||||
// -- Server part --
|
||||
setIsServer(true)
|
||||
|
||||
const prefetchCache = new QueryCache()
|
||||
const prefetchClient = new QueryClient({
|
||||
queryCache: prefetchCache,
|
||||
})
|
||||
await prefetchClient
|
||||
.query({
|
||||
queryKey: key,
|
||||
queryFn: () => fetchDataSuccess('success'),
|
||||
})
|
||||
.catch(noop)
|
||||
const dehydratedStateServer = dehydrate(prefetchClient)
|
||||
const renderCache = new QueryCache()
|
||||
const renderClient = new QueryClient({
|
||||
queryCache: renderCache,
|
||||
})
|
||||
hydrate(renderClient, dehydratedStateServer)
|
||||
const markup = ReactDOMServer.renderToString(
|
||||
<QueryClientProvider client={renderClient}>
|
||||
<SuccessComponent />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
const stringifiedState = JSON.stringify(dehydratedStateServer)
|
||||
renderClient.clear()
|
||||
setIsServer(false)
|
||||
|
||||
const expectedMarkup =
|
||||
'SuccessComponent - status:success fetching:true data:success'
|
||||
|
||||
expect(markup).toBe(expectedMarkup)
|
||||
expect(fetchDataSuccess).toHaveBeenCalledTimes(1)
|
||||
|
||||
// -- Client part --
|
||||
const el = document.createElement('div')
|
||||
el.innerHTML = markup
|
||||
|
||||
const queryCache = new QueryCache()
|
||||
const queryClient = new QueryClient({ queryCache })
|
||||
hydrate(queryClient, JSON.parse(stringifiedState))
|
||||
|
||||
const unmount = ReactHydrate(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SuccessComponent />
|
||||
</QueryClientProvider>,
|
||||
el,
|
||||
)
|
||||
|
||||
// Check that we have no React hydration mismatches
|
||||
expect(consoleMock).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(fetchDataSuccess).toHaveBeenCalledTimes(2)
|
||||
expect(el.innerHTML).toBe(expectedMarkup)
|
||||
|
||||
unmount()
|
||||
queryClient.clear()
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not mismatch on error', async () => {
|
||||
const consoleMock = vi.spyOn(console, 'error')
|
||||
consoleMock.mockImplementation(() => undefined)
|
||||
|
||||
const fetchDataError = vi.fn(() => {
|
||||
throw new Error('fetchDataError')
|
||||
})
|
||||
const key = queryKey()
|
||||
|
||||
// -- Shared part --
|
||||
function ErrorComponent() {
|
||||
const result = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => fetchDataError(),
|
||||
retry: false,
|
||||
})
|
||||
return (
|
||||
<PrintStateComponent componentName="ErrorComponent" result={result} />
|
||||
)
|
||||
}
|
||||
|
||||
// -- Server part --
|
||||
setIsServer(true)
|
||||
const prefetchCache = new QueryCache()
|
||||
const prefetchClient = new QueryClient({
|
||||
queryCache: prefetchCache,
|
||||
})
|
||||
await prefetchClient
|
||||
.query({
|
||||
queryKey: key,
|
||||
queryFn: () => fetchDataError(),
|
||||
})
|
||||
.catch(noop)
|
||||
const dehydratedStateServer = dehydrate(prefetchClient)
|
||||
const renderCache = new QueryCache()
|
||||
const renderClient = new QueryClient({
|
||||
queryCache: renderCache,
|
||||
})
|
||||
hydrate(renderClient, dehydratedStateServer)
|
||||
const markup = ReactDOMServer.renderToString(
|
||||
<QueryClientProvider client={renderClient}>
|
||||
<ErrorComponent />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
const stringifiedState = JSON.stringify(dehydratedStateServer)
|
||||
renderClient.clear()
|
||||
setIsServer(false)
|
||||
|
||||
const expectedMarkup =
|
||||
'ErrorComponent - status:pending fetching:true data:undefined'
|
||||
|
||||
expect(markup).toBe(expectedMarkup)
|
||||
|
||||
// -- Client part --
|
||||
const el = document.createElement('div')
|
||||
el.innerHTML = markup
|
||||
|
||||
const queryCache = new QueryCache()
|
||||
const queryClient = new QueryClient({ queryCache })
|
||||
hydrate(queryClient, JSON.parse(stringifiedState))
|
||||
|
||||
const unmount = ReactHydrate(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorComponent />
|
||||
</QueryClientProvider>,
|
||||
el,
|
||||
)
|
||||
|
||||
expect(consoleMock).toHaveBeenCalledTimes(0)
|
||||
expect(fetchDataError).toHaveBeenCalledTimes(2)
|
||||
expect(el.innerHTML).toBe(expectedMarkup)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(fetchDataError).toHaveBeenCalledTimes(2)
|
||||
expect(el.innerHTML).toBe(
|
||||
'ErrorComponent - status:error fetching:false data:undefined',
|
||||
)
|
||||
|
||||
unmount()
|
||||
queryClient.clear()
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not mismatch on queries that were not prefetched', async () => {
|
||||
const consoleMock = vi.spyOn(console, 'error')
|
||||
consoleMock.mockImplementation(() => undefined)
|
||||
|
||||
const fetchDataSuccess = vi.fn<typeof fetchData>(fetchData)
|
||||
const key = queryKey()
|
||||
|
||||
// -- Shared part --
|
||||
function SuccessComponent() {
|
||||
const result = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => fetchDataSuccess('success!'),
|
||||
})
|
||||
return (
|
||||
<PrintStateComponent componentName="SuccessComponent" result={result} />
|
||||
)
|
||||
}
|
||||
|
||||
// -- Server part --
|
||||
setIsServer(true)
|
||||
|
||||
const prefetchClient = new QueryClient()
|
||||
const dehydratedStateServer = dehydrate(prefetchClient)
|
||||
const renderClient = new QueryClient()
|
||||
hydrate(renderClient, dehydratedStateServer)
|
||||
const markup = ReactDOMServer.renderToString(
|
||||
<QueryClientProvider client={renderClient}>
|
||||
<SuccessComponent />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
const stringifiedState = JSON.stringify(dehydratedStateServer)
|
||||
renderClient.clear()
|
||||
setIsServer(false)
|
||||
|
||||
const expectedMarkup =
|
||||
'SuccessComponent - status:pending fetching:true data:undefined'
|
||||
|
||||
expect(markup).toBe(expectedMarkup)
|
||||
|
||||
// -- Client part --
|
||||
const el = document.createElement('div')
|
||||
el.innerHTML = markup
|
||||
|
||||
const queryCache = new QueryCache()
|
||||
const queryClient = new QueryClient({ queryCache })
|
||||
hydrate(queryClient, JSON.parse(stringifiedState))
|
||||
|
||||
const unmount = ReactHydrate(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SuccessComponent />
|
||||
</QueryClientProvider>,
|
||||
el,
|
||||
)
|
||||
|
||||
// Check that we have no React hydration mismatches
|
||||
expect(consoleMock).toHaveBeenCalledTimes(0)
|
||||
expect(fetchDataSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(el.innerHTML).toBe(expectedMarkup)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(fetchDataSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(el.innerHTML).toBe(
|
||||
'SuccessComponent - status:success fetching:false data:success!',
|
||||
)
|
||||
|
||||
unmount()
|
||||
queryClient.clear()
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
})
|
||||
277
packages/react-query/src/__tests__/ssr.test.tsx
Normal file
277
packages/react-query/src/__tests__/ssr.test.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import * as React from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
noop,
|
||||
useInfiniteQuery,
|
||||
useIsFetching,
|
||||
useMutation,
|
||||
useMutationState,
|
||||
useQueries,
|
||||
useQuery,
|
||||
} from '..'
|
||||
import { setIsServer } from './utils'
|
||||
|
||||
describe('Server Side Rendering', () => {
|
||||
setIsServer(true)
|
||||
|
||||
let queryCache: QueryCache
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryCache = new QueryCache()
|
||||
queryClient = new QueryClient({ queryCache })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should not trigger fetch', () => {
|
||||
const key = queryKey()
|
||||
const queryFn = vi.fn(() => sleep(10).then(() => 'data'))
|
||||
|
||||
function Page() {
|
||||
const query = useQuery({ queryKey: key, queryFn })
|
||||
|
||||
const content = `status ${query.status}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('status pending')
|
||||
expect(queryFn).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it('should add prefetched data to cache', async () => {
|
||||
const key = queryKey()
|
||||
|
||||
const promise = queryClient.query({
|
||||
queryKey: key,
|
||||
queryFn: () => sleep(10).then(() => 'data'),
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
const data = await promise
|
||||
|
||||
expect(data).toBe('data')
|
||||
expect(queryCache.find({ queryKey: key })?.state.data).toBe('data')
|
||||
})
|
||||
|
||||
it('should return existing data from the cache', async () => {
|
||||
const key = queryKey()
|
||||
const queryFn = vi.fn(() => sleep(10).then(() => 'data'))
|
||||
|
||||
function Page() {
|
||||
const query = useQuery({ queryKey: key, queryFn })
|
||||
|
||||
const content = `status ${query.status}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
void queryClient.query({ queryKey: key, queryFn }).catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('status success')
|
||||
expect(queryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should add initialData to the cache', () => {
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
const [page, setPage] = React.useState(1)
|
||||
const { data } = useQuery({
|
||||
queryKey: [key, page],
|
||||
queryFn: () => sleep(10).then(() => page),
|
||||
initialData: 1,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 data-testid="title">{data}</h1>
|
||||
<button onClick={() => setPage(page + 1)}>next</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
const keys = queryCache.getAll().map((query) => query.queryKey)
|
||||
|
||||
expect(keys).toEqual([[key, 1]])
|
||||
})
|
||||
|
||||
it('useInfiniteQuery should return the correct state', async () => {
|
||||
const key = queryKey()
|
||||
const queryFn = vi.fn(() => sleep(10).then(() => 'page 1'))
|
||||
|
||||
function Page() {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: key,
|
||||
queryFn,
|
||||
getNextPageParam: () => undefined,
|
||||
initialPageParam: 0,
|
||||
})
|
||||
return (
|
||||
<ul>
|
||||
{query.data?.pages.map((page) => (
|
||||
<li key={page}>{page}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
void queryClient
|
||||
.infiniteQuery({
|
||||
queryKey: key,
|
||||
queryFn,
|
||||
initialPageParam: 0,
|
||||
})
|
||||
.catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('page 1')
|
||||
expect(queryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('useIsFetching should return 0 after prefetch completes', async () => {
|
||||
const key = queryKey()
|
||||
const queryFn = () => sleep(10).then(() => 'data')
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({ queryKey: key, queryFn })
|
||||
const isFetching = useIsFetching()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{data}</div>
|
||||
<div>{`isFetching: ${isFetching}`}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
void queryClient.query({ queryKey: key, queryFn }).catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('data')
|
||||
expect(markup).toContain('isFetching: 0')
|
||||
})
|
||||
|
||||
it('useQueries should return existing data from the cache', async () => {
|
||||
const key1 = queryKey()
|
||||
const key2 = queryKey()
|
||||
const queryFn1 = () => sleep(10).then(() => 'data1')
|
||||
const queryFn2 = () => sleep(10).then(() => 'data2')
|
||||
|
||||
function Page() {
|
||||
const queries = useQueries({
|
||||
queries: [
|
||||
{ queryKey: key1, queryFn: queryFn1 },
|
||||
{ queryKey: key2, queryFn: queryFn2 },
|
||||
],
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{`status1: ${queries[0].status}`}</div>
|
||||
<div>{`status2: ${queries[1].status}`}</div>
|
||||
<div>{`data1: ${queries[0].data}`}</div>
|
||||
<div>{`data2: ${queries[1].data}`}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
void queryClient.query({ queryKey: key1, queryFn: queryFn1 }).catch(noop)
|
||||
void queryClient.query({ queryKey: key2, queryFn: queryFn2 }).catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('status1: success')
|
||||
expect(markup).toContain('status2: success')
|
||||
expect(markup).toContain('data1: data1')
|
||||
expect(markup).toContain('data2: data2')
|
||||
})
|
||||
|
||||
it('useMutation should return idle status', () => {
|
||||
function Page() {
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => sleep(10).then(() => 'data'),
|
||||
})
|
||||
|
||||
return <div>{`status: ${mutation.status}`}</div>
|
||||
}
|
||||
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('status: idle')
|
||||
})
|
||||
|
||||
it('useMutationState should return empty array', () => {
|
||||
function Page() {
|
||||
const mutationState = useMutationState()
|
||||
|
||||
return <div>{`mutationState: ${mutationState.length}`}</div>
|
||||
}
|
||||
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('mutationState: 0')
|
||||
})
|
||||
})
|
||||
238
packages/react-query/src/__tests__/suspense.test.tsx
Normal file
238
packages/react-query/src/__tests__/suspense.test.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { Suspense } from 'react'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { QueryClient, QueryClientProvider, useSuspenseQuery } from '..'
|
||||
import type { StaleTime } from '@tanstack/query-core'
|
||||
import type { QueryKey } from '..'
|
||||
|
||||
function renderWithSuspense(client: QueryClient, ui: React.ReactNode) {
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Suspense fallback="loading">{ui}</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function createTestQuery(options: {
|
||||
fetchCount: { count: number }
|
||||
queryKey: QueryKey
|
||||
staleTime?: StaleTime | (() => StaleTime)
|
||||
}) {
|
||||
return function TestComponent() {
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: options.queryKey,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
options.fetchCount.count++
|
||||
return 'data'
|
||||
}),
|
||||
staleTime: options.staleTime,
|
||||
})
|
||||
return <div>data: {data}</div>
|
||||
}
|
||||
}
|
||||
|
||||
describe('Suspense Timer Tests', () => {
|
||||
let queryClient: QueryClient
|
||||
let fetchCount: { count: number }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
fetchCount = { count: 0 }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should enforce minimum staleTime of 1000ms when using suspense with number', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: 10,
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(100))
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
|
||||
it('should enforce minimum staleTime of 1000ms when using suspense with function', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: () => 10,
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(100))
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
|
||||
it('should respect staleTime when value is greater than 1000ms', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: 2000,
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(1500))
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
|
||||
it('should enforce minimum staleTime when undefined is provided', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: undefined,
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(500))
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
|
||||
it('should preserve staleTime when value is static', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: 'static',
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(2000))
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
|
||||
it('should preserve staleTime when function returns static', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: () => 'static',
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(2000))
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
|
||||
it('should respect staleTime when function returns value greater than 1000ms', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: () => 3000,
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(2000))
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
})
|
||||
110
packages/react-query/src/__tests__/transition.test.tsx
Normal file
110
packages/react-query/src/__tests__/transition.test.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as React from 'react'
|
||||
import { act, fireEvent } from '@testing-library/react'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { Suspense, startTransition, useDeferredValue } from 'react'
|
||||
import { QueryClient, useSuspenseQuery } from '..'
|
||||
import { renderWithClient } from './utils'
|
||||
|
||||
describe('react transitions', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryClient = new QueryClient()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should keep values of old key around with useDeferredValue', async () => {
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
const [count, setCount] = React.useState(0)
|
||||
const deferredCount = useDeferredValue(count)
|
||||
const query = useSuspenseQuery({
|
||||
queryKey: [key, deferredCount],
|
||||
queryFn: () => sleep(10).then(() => 'test' + deferredCount),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setCount((c) => c + 1)}>increment</button>
|
||||
<div>data: {query.data}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = await renderWithClient(
|
||||
queryClient,
|
||||
<Suspense fallback="loading">
|
||||
<Page />
|
||||
</Suspense>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
|
||||
expect(rendered.getByText('data: test0')).toBeInTheDocument()
|
||||
|
||||
await act(() =>
|
||||
fireEvent.click(rendered.getByRole('button', { name: 'increment' })),
|
||||
)
|
||||
|
||||
expect(rendered.getByText('data: test0')).toBeVisible()
|
||||
expect(rendered.queryByText('loading')).not.toBeInTheDocument()
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
|
||||
expect(rendered.getByText('data: test1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep values of old key around with startTransition', async () => {
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
const [count, setCount] = React.useState(0)
|
||||
const query = useSuspenseQuery({
|
||||
queryKey: [key, count],
|
||||
queryFn: () => sleep(10).then(() => 'test' + count),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => startTransition(() => setCount((c) => c + 1))}>
|
||||
increment
|
||||
</button>
|
||||
<div>data: {query.data}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = await renderWithClient(
|
||||
queryClient,
|
||||
<Suspense fallback="loading">
|
||||
<Page />
|
||||
</Suspense>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
|
||||
expect(rendered.getByText('data: test0')).toBeInTheDocument()
|
||||
|
||||
await act(() =>
|
||||
fireEvent.click(rendered.getByRole('button', { name: 'increment' })),
|
||||
)
|
||||
|
||||
expect(rendered.getByText('data: test0')).toBeVisible()
|
||||
expect(rendered.queryByText('loading')).not.toBeInTheDocument()
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
|
||||
expect(rendered.getByText('data: test1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
176
packages/react-query/src/__tests__/useInfiniteQuery.test-d.tsx
Normal file
176
packages/react-query/src/__tests__/useInfiniteQuery.test-d.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, expectTypeOf, it } from 'vitest'
|
||||
import { QueryClient } from '@tanstack/query-core'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { useInfiniteQuery } from '../useInfiniteQuery'
|
||||
import type { InfiniteData } from '@tanstack/query-core'
|
||||
import type { UseInfiniteQueryOptions } from '../types'
|
||||
|
||||
describe('pageParam', () => {
|
||||
it('initialPageParam should define type of param passed to queryFunctionContext', () => {
|
||||
useInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
expectTypeOf(pageParam).toEqualTypeOf<number>()
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('direction should be passed to queryFn of useInfiniteQuery', () => {
|
||||
useInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ direction }) => {
|
||||
expectTypeOf(direction).toEqualTypeOf<'forward' | 'backward'>()
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('initialPageParam should define type of param passed to queryFunctionContext for fetchInfiniteQuery', () => {
|
||||
const queryClient = new QueryClient()
|
||||
// grandfathered direct test
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
queryClient.fetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
expectTypeOf(pageParam).toEqualTypeOf<number>()
|
||||
},
|
||||
initialPageParam: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('initialPageParam should define type of param passed to queryFunctionContext for infiniteQuery', () => {
|
||||
const queryClient = new QueryClient()
|
||||
queryClient.infiniteQuery({
|
||||
queryKey: ['key'],
|
||||
queryFn: ({ pageParam }) => {
|
||||
expectTypeOf(pageParam).toEqualTypeOf<number>()
|
||||
return Promise.resolve(pageParam)
|
||||
},
|
||||
initialPageParam: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('initialPageParam should define type of param passed to queryFunctionContext for prefetchInfiniteQuery', () => {
|
||||
const queryClient = new QueryClient()
|
||||
// grandfathered direct test
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
queryClient.prefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
expectTypeOf(pageParam).toEqualTypeOf<number>()
|
||||
},
|
||||
initialPageParam: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('select', () => {
|
||||
it('should still return paginated data if no select result', () => {
|
||||
const infiniteQuery = useInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
return pageParam * 5
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => undefined,
|
||||
})
|
||||
|
||||
// TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now
|
||||
expectTypeOf(infiniteQuery.data).toEqualTypeOf<
|
||||
InfiniteData<number, unknown> | undefined
|
||||
>()
|
||||
})
|
||||
|
||||
it('should be able to transform data to arbitrary result', () => {
|
||||
const infiniteQuery = useInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
return pageParam * 5
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => undefined,
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<number, number>>()
|
||||
return 'selected' as const
|
||||
},
|
||||
})
|
||||
|
||||
expectTypeOf(infiniteQuery.data).toEqualTypeOf<'selected' | undefined>()
|
||||
})
|
||||
})
|
||||
describe('getNextPageParam / getPreviousPageParam', () => {
|
||||
it('should get typed params', () => {
|
||||
const infiniteQuery = useInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
return String(pageParam)
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
expectTypeOf(lastPage).toEqualTypeOf<string>()
|
||||
expectTypeOf(allPages).toEqualTypeOf<Array<string>>()
|
||||
expectTypeOf(lastPageParam).toEqualTypeOf<number>()
|
||||
expectTypeOf(allPageParams).toEqualTypeOf<Array<number>>()
|
||||
return undefined
|
||||
},
|
||||
getPreviousPageParam: (
|
||||
firstPage,
|
||||
allPages,
|
||||
firstPageParam,
|
||||
allPageParams,
|
||||
) => {
|
||||
expectTypeOf(firstPage).toEqualTypeOf<string>()
|
||||
expectTypeOf(allPages).toEqualTypeOf<Array<string>>()
|
||||
expectTypeOf(firstPageParam).toEqualTypeOf<number>()
|
||||
expectTypeOf(allPageParams).toEqualTypeOf<Array<number>>()
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
// TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now
|
||||
expectTypeOf(infiniteQuery.data).toEqualTypeOf<
|
||||
InfiniteData<string, unknown> | undefined
|
||||
>()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error booleans', () => {
|
||||
it('should not be permanently `false`', () => {
|
||||
const {
|
||||
isFetchNextPageError,
|
||||
isFetchPreviousPageError,
|
||||
isLoadingError,
|
||||
isRefetchError,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
return pageParam * 5
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => undefined,
|
||||
})
|
||||
|
||||
expectTypeOf(isFetchNextPageError).toEqualTypeOf<boolean>()
|
||||
expectTypeOf(isFetchPreviousPageError).toEqualTypeOf<boolean>()
|
||||
expectTypeOf(isLoadingError).toEqualTypeOf<boolean>()
|
||||
expectTypeOf(isRefetchError).toEqualTypeOf<boolean>()
|
||||
})
|
||||
})
|
||||
|
||||
describe('UseInfiniteQueryOptions', () => {
|
||||
it('should default TData to InfiniteData<TQueryFnData>', () => {
|
||||
const options: UseInfiniteQueryOptions<number, Error> = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 5,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => undefined,
|
||||
}
|
||||
const infiniteQuery = useInfiniteQuery(options)
|
||||
|
||||
expectTypeOf(infiniteQuery.data).toEqualTypeOf<
|
||||
InfiniteData<number, unknown> | undefined
|
||||
>()
|
||||
})
|
||||
})
|
||||
1848
packages/react-query/src/__tests__/useInfiniteQuery.test.tsx
Normal file
1848
packages/react-query/src/__tests__/useInfiniteQuery.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
246
packages/react-query/src/__tests__/useIsFetching.test.tsx
Normal file
246
packages/react-query/src/__tests__/useIsFetching.test.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { QueryCache, QueryClient, useIsFetching, useQuery } from '..'
|
||||
import { renderWithClient, setActTimeout } from './utils'
|
||||
|
||||
describe('useIsFetching', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// See https://github.com/tannerlinsley/react-query/issues/105
|
||||
it('should update as queries start and stop fetching', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
|
||||
function IsFetching() {
|
||||
const isFetching = useIsFetching()
|
||||
|
||||
return <div>isFetching: {isFetching}</div>
|
||||
}
|
||||
|
||||
function Query() {
|
||||
const [ready, setReady] = React.useState(false)
|
||||
|
||||
useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => sleep(50).then(() => 'test'),
|
||||
enabled: ready,
|
||||
})
|
||||
|
||||
return <button onClick={() => setReady(true)}>setReady</button>
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<IsFetching />
|
||||
<Query />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /setReady/i }))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rendered.getByText('isFetching: 1')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(51)
|
||||
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not update state while rendering', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const key1 = queryKey()
|
||||
const key2 = queryKey()
|
||||
|
||||
const isFetchingArray: Array<number> = []
|
||||
|
||||
function IsFetching() {
|
||||
const isFetching = useIsFetching()
|
||||
|
||||
isFetchingArray.push(isFetching)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function FirstQuery() {
|
||||
useQuery({
|
||||
queryKey: key1,
|
||||
queryFn: () => sleep(100).then(() => 'data1'),
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function SecondQuery() {
|
||||
useQuery({
|
||||
queryKey: key2,
|
||||
queryFn: () => sleep(100).then(() => 'data2'),
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const [renderSecond, setRenderSecond] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setActTimeout(() => {
|
||||
setRenderSecond(true)
|
||||
}, 50)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<IsFetching />
|
||||
<FirstQuery />
|
||||
{renderSecond && <SecondQuery />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
renderWithClient(queryClient, <Page />)
|
||||
|
||||
expect(isFetchingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isFetchingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(isFetchingArray[2]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(isFetchingArray[3]).toEqual(2)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(isFetchingArray[4]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(isFetchingArray[5]).toEqual(0)
|
||||
|
||||
expect(isFetchingArray).toEqual([0, 1, 1, 2, 1, 0])
|
||||
})
|
||||
|
||||
it('should be able to filter', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const key1 = queryKey()
|
||||
const key2 = queryKey()
|
||||
|
||||
const isFetchingArray: Array<number> = []
|
||||
|
||||
function One() {
|
||||
useQuery({
|
||||
queryKey: key1,
|
||||
queryFn: () => sleep(10).then(() => 'test1'),
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Two() {
|
||||
useQuery({
|
||||
queryKey: key2,
|
||||
queryFn: () => sleep(20).then(() => 'test2'),
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const [started, setStarted] = React.useState(false)
|
||||
const isFetching = useIsFetching({ queryKey: key1 })
|
||||
|
||||
isFetchingArray.push(isFetching)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setStarted(true)}>setStarted</button>
|
||||
<div>isFetching: {isFetching}</div>
|
||||
{started ? (
|
||||
<>
|
||||
<One />
|
||||
<Two />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /setStarted/i }))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rendered.getByText('isFetching: 1')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
|
||||
|
||||
// at no point should we have isFetching: 2
|
||||
expect(isFetchingArray).toEqual(expect.not.arrayContaining([2]))
|
||||
})
|
||||
|
||||
it('should show the correct fetching state when mounted after a query', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => sleep(10).then(() => 'test'),
|
||||
})
|
||||
|
||||
const isFetching = useIsFetching()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>isFetching: {isFetching}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
expect(rendered.getByText('isFetching: 1')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use provided custom queryClient', async () => {
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const queryCache = new QueryCache({ onSuccess })
|
||||
const queryClient = new QueryClient({ queryCache })
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
useQuery(
|
||||
{
|
||||
queryKey: key,
|
||||
queryFn: () => sleep(10).then(() => 'test'),
|
||||
},
|
||||
queryClient,
|
||||
)
|
||||
|
||||
const isFetching = useIsFetching({}, queryClient)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>isFetching: {isFetching}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(<Page />)
|
||||
|
||||
expect(rendered.getByText('isFetching: 1')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
|
||||
expect(onSuccess).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
203
packages/react-query/src/__tests__/useMutation.test-d.tsx
Normal file
203
packages/react-query/src/__tests__/useMutation.test-d.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expectTypeOf, it } from 'vitest'
|
||||
import { QueryClient } from '@tanstack/query-core'
|
||||
import { useMutation } from '../useMutation'
|
||||
import type {
|
||||
DefaultError,
|
||||
MutationFunctionContext,
|
||||
MutationKey,
|
||||
} from '@tanstack/query-core'
|
||||
import type { UseMutationResult } from '../types'
|
||||
|
||||
describe('useMutation', () => {
|
||||
it('should infer TData from mutationFn return type', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(mutation.error).toEqualTypeOf<DefaultError | null>()
|
||||
})
|
||||
|
||||
it('should infer TVariables from mutationFn parameter', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: (vars: { id: string }) => Promise.resolve(vars.id),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.mutate).toBeCallableWith({ id: '1' })
|
||||
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
|
||||
})
|
||||
|
||||
it('should infer TOnMutateResult from onMutate return type', () => {
|
||||
useMutation({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
onMutate: () => {
|
||||
return { token: 'abc' }
|
||||
},
|
||||
onSuccess: (_data, _variables, onMutateResult) => {
|
||||
expectTypeOf(onMutateResult).toEqualTypeOf<{ token: string }>()
|
||||
},
|
||||
onError: (_error, _variables, onMutateResult) => {
|
||||
expectTypeOf(onMutateResult).toEqualTypeOf<
|
||||
{ token: string } | undefined
|
||||
>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow explicit generic types', () => {
|
||||
const mutation = useMutation<string, Error, { id: number }>({
|
||||
mutationFn: (vars) => {
|
||||
expectTypeOf(vars).toEqualTypeOf<{ id: number }>()
|
||||
return Promise.resolve('result')
|
||||
},
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(mutation.error).toEqualTypeOf<Error | null>()
|
||||
})
|
||||
|
||||
it('should return correct UseMutationResult type', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => Promise.resolve(42),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation).toEqualTypeOf<
|
||||
UseMutationResult<number, DefaultError, void, unknown>
|
||||
>()
|
||||
})
|
||||
|
||||
it('should type mutateAsync with correct return type', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: (id: string) => Promise.resolve(id.length),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.mutateAsync).toBeCallableWith('test')
|
||||
expectTypeOf(mutation.mutateAsync('test')).toEqualTypeOf<Promise<number>>()
|
||||
})
|
||||
|
||||
it('should default TVariables to void when mutationFn has no parameters', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.mutate).toBeCallableWith()
|
||||
})
|
||||
|
||||
it('should allow calling mutate with no arguments when TVariables is optional undefinable', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: (_variables: number | undefined) => Promise.resolve(1),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.mutate).toBeCallableWith()
|
||||
expectTypeOf(mutation.mutateAsync).toBeCallableWith()
|
||||
})
|
||||
|
||||
it('should infer custom TError type', () => {
|
||||
class CustomError extends Error {
|
||||
code: number
|
||||
constructor(code: number) {
|
||||
super()
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
const mutation = useMutation<string, CustomError>({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.error).toEqualTypeOf<CustomError | null>()
|
||||
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
|
||||
})
|
||||
|
||||
it('should infer types for onSettled callback', () => {
|
||||
useMutation({
|
||||
mutationFn: () => Promise.resolve(42),
|
||||
onSettled: (data, error, _variables, _onMutateResult) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
expectTypeOf(error).toEqualTypeOf<DefaultError | null>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer custom TError in onError callback', () => {
|
||||
class CustomError extends Error {
|
||||
code: number
|
||||
constructor(code: number) {
|
||||
super()
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
useMutation<string, CustomError>({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
onError: (error) => {
|
||||
expectTypeOf(error).toEqualTypeOf<CustomError>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should accept queryClient as second argument', () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const mutation = useMutation(
|
||||
{
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
},
|
||||
queryClient,
|
||||
)
|
||||
|
||||
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
|
||||
})
|
||||
|
||||
it('should type context as the last argument for mutationFn and every hook-level callback', () => {
|
||||
useMutation({
|
||||
mutationFn: (_variables, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
expectTypeOf(context.client).toEqualTypeOf<QueryClient>()
|
||||
return Promise.resolve('data')
|
||||
},
|
||||
onMutate: (_variables, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onSuccess: (_data, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onError: (_error, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onSettled: (_data, _error, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should type context as the last argument for every per-call mutate option', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
})
|
||||
|
||||
mutation.mutate(undefined, {
|
||||
onSuccess: (_data, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onError: (_error, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onSettled: (_data, _error, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should type context.mutationKey as MutationKey', () => {
|
||||
useMutation({
|
||||
mutationKey: ['todos', 'add'] as const,
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
onSuccess: (_data, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context.mutationKey).toEqualTypeOf<
|
||||
MutationKey | undefined
|
||||
>()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
2579
packages/react-query/src/__tests__/useMutation.test.tsx
Normal file
2579
packages/react-query/src/__tests__/useMutation.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
import { describe, expectTypeOf, it } from 'vitest'
|
||||
import { useMutationState } from '../useMutationState'
|
||||
import type {
|
||||
Mutation,
|
||||
MutationState,
|
||||
MutationStatus,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
describe('useMutationState', () => {
|
||||
it('should default to QueryState', () => {
|
||||
const result = useMutationState({
|
||||
filters: { status: 'pending' },
|
||||
})
|
||||
|
||||
expectTypeOf(result).toEqualTypeOf<
|
||||
Array<MutationState<unknown, Error, unknown, unknown>>
|
||||
>()
|
||||
})
|
||||
it('should infer with select', () => {
|
||||
const result = useMutationState({
|
||||
filters: { status: 'pending' },
|
||||
select: (mutation) => mutation.state.status,
|
||||
})
|
||||
|
||||
expectTypeOf(result).toEqualTypeOf<Array<MutationStatus>>()
|
||||
})
|
||||
it('should propagate generics to select callback when TResult is typed MutationState', () => {
|
||||
type MyData = { data: Array<string> }
|
||||
type MyError = { code: number; message: string }
|
||||
type MyVars = { id: number }
|
||||
|
||||
useMutationState<MutationState<MyData, MyError, MyVars>>({
|
||||
filters: { mutationKey: ['key'] },
|
||||
select: (mutation) => {
|
||||
expectTypeOf(mutation).toEqualTypeOf<
|
||||
Mutation<MyData, MyError, MyVars, unknown>
|
||||
>()
|
||||
return mutation.state
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
244
packages/react-query/src/__tests__/useMutationState.test.tsx
Normal file
244
packages/react-query/src/__tests__/useMutationState.test.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { QueryClient, useIsMutating, useMutation, useMutationState } from '..'
|
||||
import { renderWithClient } from './utils'
|
||||
|
||||
describe('useIsMutating', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const key1 = queryKey()
|
||||
const key2 = queryKey()
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating()
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Mutations() {
|
||||
const { mutate: mutate1 } = useMutation({
|
||||
mutationKey: key1,
|
||||
mutationFn: () => sleep(50).then(() => 'data'),
|
||||
})
|
||||
const { mutate: mutate2 } = useMutation({
|
||||
mutationKey: key2,
|
||||
mutationFn: () => sleep(10).then(() => 'data'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<IsMutating />
|
||||
<Mutations />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
|
||||
// we don't really care if this yields
|
||||
// [ +0, 1, 2, +0 ]
|
||||
// or
|
||||
// [ +0, 1, 2, 1, +0 ]
|
||||
// our batching strategy might yield different results
|
||||
|
||||
await vi.advanceTimersByTimeAsync(41)
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
expect(isMutatingArray[2]).toEqual(2)
|
||||
expect(isMutatingArray[3]).toEqual(1)
|
||||
expect(isMutatingArray[4]).toEqual(0)
|
||||
|
||||
expect(isMutatingArray).toEqual([0, 1, 2, 1, 0])
|
||||
})
|
||||
|
||||
it('should filter correctly by mutationKey', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const key1 = queryKey()
|
||||
const key2 = queryKey()
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating({ mutationKey: key1 })
|
||||
isMutatingArray.push(isMutating)
|
||||
return null
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const { mutate: mutate1 } = useMutation({
|
||||
mutationKey: key1,
|
||||
mutationFn: () => sleep(100).then(() => 'data'),
|
||||
})
|
||||
const { mutate: mutate2 } = useMutation({
|
||||
mutationKey: key2,
|
||||
mutationFn: () => sleep(100).then(() => 'data'),
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
mutate1()
|
||||
mutate2()
|
||||
}, [mutate1, mutate2])
|
||||
|
||||
return <IsMutating />
|
||||
}
|
||||
|
||||
renderWithClient(queryClient, <Page />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(101)
|
||||
expect(isMutatingArray).toEqual([0, 1, 0])
|
||||
})
|
||||
|
||||
it('should filter correctly by predicate', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const key1 = queryKey()
|
||||
const key2 = queryKey()
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating({
|
||||
predicate: (mutation) => mutation.options.mutationKey?.[0] === key1[0],
|
||||
})
|
||||
isMutatingArray.push(isMutating)
|
||||
return null
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const { mutate: mutate1 } = useMutation({
|
||||
mutationKey: key1,
|
||||
mutationFn: () => sleep(100).then(() => 'data'),
|
||||
})
|
||||
const { mutate: mutate2 } = useMutation({
|
||||
mutationKey: key2,
|
||||
mutationFn: () => sleep(100).then(() => 'data'),
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
mutate1()
|
||||
mutate2()
|
||||
}, [mutate1, mutate2])
|
||||
|
||||
return <IsMutating />
|
||||
}
|
||||
|
||||
renderWithClient(queryClient, <Page />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(101)
|
||||
expect(isMutatingArray).toEqual([0, 1, 0])
|
||||
})
|
||||
|
||||
it('should use provided custom queryClient', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
const isMutating = useIsMutating({}, queryClient)
|
||||
const { mutate } = useMutation(
|
||||
{
|
||||
mutationKey: key,
|
||||
mutationFn: () => sleep(10).then(() => 'data'),
|
||||
},
|
||||
queryClient,
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
mutate()
|
||||
}, [mutate])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>mutating: {isMutating}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(<Page />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rendered.getByText('mutating: 1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMutationState', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should return variables after calling mutate', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const variables: Array<Array<unknown>> = []
|
||||
const mutationKey = ['mutation']
|
||||
|
||||
function Variables() {
|
||||
variables.push(
|
||||
useMutationState({
|
||||
filters: { mutationKey, status: 'pending' },
|
||||
select: (mutation) => mutation.state.variables,
|
||||
}),
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Mutate() {
|
||||
const { mutate, data } = useMutation({
|
||||
mutationKey,
|
||||
mutationFn: (input: number) => sleep(150).then(() => 'data' + input),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
data: {data ?? 'null'}
|
||||
<button onClick={() => mutate(1)}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<Variables />
|
||||
<Mutate />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
expect(rendered.getByText('data: null')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
await vi.advanceTimersByTimeAsync(151)
|
||||
expect(rendered.getByText('data: data1')).toBeInTheDocument()
|
||||
|
||||
expect(variables).toEqual([[], [1], []])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { skipToken, usePrefetchInfiniteQuery } from '..'
|
||||
|
||||
describe('usePrefetchInfiniteQuery', () => {
|
||||
it('should return nothing', () => {
|
||||
const result = usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
})
|
||||
|
||||
expectTypeOf(result).toEqualTypeOf<void>()
|
||||
})
|
||||
|
||||
it('should require initialPageParam and getNextPageParam', () => {
|
||||
assertType(
|
||||
// @ts-expect-error TS2345
|
||||
usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not allow refetchInterval, enabled or throwOnError options', () => {
|
||||
assertType(
|
||||
usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error TS2353
|
||||
refetchInterval: 1000,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error TS2353
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error TS2353
|
||||
throwOnError: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not allow skipToken in queryFn', () => {
|
||||
assertType(
|
||||
usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: skipToken,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
}),
|
||||
)
|
||||
assertType(
|
||||
usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as React from 'react'
|
||||
import { act, fireEvent } from '@testing-library/react'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
noop,
|
||||
usePrefetchInfiniteQuery,
|
||||
useSuspenseInfiniteQuery,
|
||||
} from '..'
|
||||
import { renderWithClient } from './utils'
|
||||
|
||||
describe('usePrefetchInfiniteQuery', () => {
|
||||
let queryCache: QueryCache
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryCache = new QueryCache()
|
||||
queryClient = new QueryClient({ queryCache })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should prefetch an infinite query if query state does not exist', async () => {
|
||||
const Fallback = vi.fn().mockImplementation(() => <div>Loading...</div>)
|
||||
const data = [
|
||||
'Do you fetch on render?',
|
||||
'Or do you render as you fetch?',
|
||||
'Either way, Tanstack Query helps you!',
|
||||
]
|
||||
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi
|
||||
.fn<(context: { pageParam: number }) => Promise<string>>()
|
||||
.mockImplementation(({ pageParam }) =>
|
||||
sleep(10).then(() => data[pageParam]!),
|
||||
),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (_lastPage: string, allPages: Array<string>) =>
|
||||
allPages.length < data.length ? allPages.length : undefined,
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseInfiniteQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.data.pages.map((page, index) => (
|
||||
<div key={index}>data: {page}</div>
|
||||
))}
|
||||
<button onClick={() => state.fetchNextPage()}>Next Page</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
usePrefetchInfiniteQuery({ ...queryOpts, pages: data.length })
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<Fallback />}>
|
||||
<Page />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(30))
|
||||
rendered.getByText('data: Do you fetch on render?')
|
||||
fireEvent.click(rendered.getByText('Next Page'))
|
||||
expect(
|
||||
rendered.getByText('data: Or do you render as you fetch?'),
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(rendered.getByText('Next Page'))
|
||||
expect(
|
||||
rendered.getByText('data: Either way, Tanstack Query helps you!'),
|
||||
).toBeInTheDocument()
|
||||
expect(Fallback).toHaveBeenCalledTimes(1)
|
||||
expect(queryOpts.queryFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should not display fallback if the query cache is already populated', async () => {
|
||||
const Fallback = vi.fn().mockImplementation(() => <div>Loading...</div>)
|
||||
const data = [
|
||||
'Prefetch rocks!',
|
||||
'No waterfalls, boy!',
|
||||
'Tanstack Query #ftw',
|
||||
]
|
||||
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi
|
||||
.fn<(context: { pageParam: number }) => Promise<string>>()
|
||||
.mockImplementation(({ pageParam }) =>
|
||||
sleep(10).then(() => data[pageParam]!),
|
||||
),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (_lastPage: string, allPages: Array<string>) =>
|
||||
allPages.length < data.length ? allPages.length : undefined,
|
||||
}
|
||||
|
||||
void queryClient.infiniteQuery({ ...queryOpts, pages: 3 }).catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(30)
|
||||
queryOpts.queryFn.mockClear()
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseInfiniteQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.data.pages.map((page, index) => (
|
||||
<div key={index}>data: {page}</div>
|
||||
))}
|
||||
<button onClick={() => state.fetchNextPage()}>Next Page</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
usePrefetchInfiniteQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<Fallback />}>
|
||||
<Page />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
expect(rendered.getByText('data: Prefetch rocks!')).toBeInTheDocument()
|
||||
fireEvent.click(rendered.getByText('Next Page'))
|
||||
expect(rendered.getByText('data: No waterfalls, boy!')).toBeInTheDocument()
|
||||
fireEvent.click(rendered.getByText('Next Page'))
|
||||
expect(rendered.getByText('data: Tanstack Query #ftw')).toBeInTheDocument()
|
||||
expect(queryOpts.queryFn).not.toHaveBeenCalled()
|
||||
expect(Fallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not create an endless loop when using inside a suspense boundary', async () => {
|
||||
const data = ['Infinite Page 1', 'Infinite Page 2', 'Infinite Page 3']
|
||||
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi
|
||||
.fn<(context: { pageParam: number }) => Promise<string>>()
|
||||
.mockImplementation(({ pageParam }) =>
|
||||
sleep(10).then(() => data[pageParam]!),
|
||||
),
|
||||
initialPageParam: 0,
|
||||
// always reports another page available, to guard against an endless
|
||||
// auto-advance loop rather than a bounded pagination sequence
|
||||
getNextPageParam: (_lastPage: string, allPages: Array<string>) =>
|
||||
allPages.length,
|
||||
}
|
||||
|
||||
function Prefetch({ children }: { children: React.ReactNode }) {
|
||||
usePrefetchInfiniteQuery(queryOpts)
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseInfiniteQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.data.pages.map((page, index) => (
|
||||
<div key={index}>data: {page}</div>
|
||||
))}
|
||||
<button onClick={() => state.fetchNextPage()}>Next Page</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<React.Suspense>
|
||||
<Prefetch>
|
||||
<Page />
|
||||
</Prefetch>
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
rendered.getByText('data: Infinite Page 1')
|
||||
fireEvent.click(rendered.getByText('Next Page'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data: Infinite Page 2')).toBeInTheDocument()
|
||||
fireEvent.click(rendered.getByText('Next Page'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data: Infinite Page 3')).toBeInTheDocument()
|
||||
expect(queryOpts.queryFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { skipToken, usePrefetchQuery } from '..'
|
||||
|
||||
describe('usePrefetchQuery', () => {
|
||||
it('should return nothing', () => {
|
||||
const result = usePrefetchQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
expectTypeOf(result).toEqualTypeOf<void>()
|
||||
})
|
||||
|
||||
it('should not allow refetchInterval, enabled or throwOnError options', () => {
|
||||
assertType(
|
||||
usePrefetchQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error TS2345
|
||||
refetchInterval: 1000,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
usePrefetchQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error TS2345
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
usePrefetchQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error TS2345
|
||||
throwOnError: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not allow skipToken in queryFn', () => {
|
||||
assertType(
|
||||
usePrefetchQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: skipToken,
|
||||
}),
|
||||
)
|
||||
assertType(
|
||||
usePrefetchQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should type queryFn and select data independently', () => {
|
||||
usePrefetchQuery<string, Error, number, { value: string }>({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('data'),
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<{ value: string }>()
|
||||
|
||||
return data.value.length
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
325
packages/react-query/src/__tests__/usePrefetchQuery.test.tsx
Normal file
325
packages/react-query/src/__tests__/usePrefetchQuery.test.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as React from 'react'
|
||||
import { act, fireEvent } from '@testing-library/react'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
noop,
|
||||
usePrefetchQuery,
|
||||
useQueryErrorResetBoundary,
|
||||
useSuspenseQuery,
|
||||
} from '..'
|
||||
import { renderWithClient } from './utils'
|
||||
|
||||
describe('usePrefetchQuery', () => {
|
||||
let queryCache: QueryCache
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryCache = new QueryCache()
|
||||
queryClient = new QueryClient({ queryCache })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should prefetch query if query state does not exist', async () => {
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi.fn(() => sleep(10).then(() => 'prefetchQuery')),
|
||||
}
|
||||
|
||||
const componentQueryOpts = {
|
||||
...queryOpts,
|
||||
queryFn: () => sleep(10).then(() => 'useSuspenseQuery'),
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseQuery(componentQueryOpts)
|
||||
return <div>data: {String(state.data)}</div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
usePrefetchQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<React.Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
expect(rendered.getByText('Loading...')).toBeInTheDocument()
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: prefetchQuery')).toBeInTheDocument()
|
||||
expect(queryOpts.queryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not prefetch query if query state exists', async () => {
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi.fn(() =>
|
||||
sleep(10).then(() => 'The usePrefetchQuery hook is smart!'),
|
||||
),
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseQuery(queryOpts)
|
||||
return <div>data: {String(state.data)}</div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
usePrefetchQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<React.Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
queryClient.query(queryOpts)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
queryOpts.queryFn.mockClear()
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
expect(rendered.queryByText('fetching: true')).not.toBeInTheDocument()
|
||||
expect(
|
||||
rendered.getByText('data: The usePrefetchQuery hook is smart!'),
|
||||
).toBeInTheDocument()
|
||||
expect(queryOpts.queryFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should let errors fall through and not refetch failed queries', async () => {
|
||||
const consoleMock = vi.spyOn(console, 'error')
|
||||
consoleMock.mockImplementation(() => undefined)
|
||||
const queryFn = vi.fn(() => sleep(10).then(() => 'Not an error'))
|
||||
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn,
|
||||
}
|
||||
|
||||
queryFn.mockImplementationOnce(() =>
|
||||
sleep(10).then(() => {
|
||||
throw new Error('Oops! Server error!')
|
||||
}),
|
||||
)
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseQuery(queryOpts)
|
||||
return <div>data: {String(state.data)}</div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
usePrefetchQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallbackRender={() => <div>Oops!</div>}>
|
||||
<React.Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
void queryClient.query(queryOpts).catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
queryFn.mockClear()
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
expect(rendered.getByText('Oops!')).toBeInTheDocument()
|
||||
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 () => {
|
||||
const queryFn = vi.fn(() => sleep(10).then(() => 'prefetchedQuery'))
|
||||
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn,
|
||||
}
|
||||
|
||||
function Prefetch({ children }: { children: React.ReactNode }) {
|
||||
usePrefetchQuery(queryOpts)
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseQuery(queryOpts)
|
||||
return <div>data: {String(state.data)}</div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<React.Suspense>
|
||||
<Prefetch>
|
||||
<Page />
|
||||
</Prefetch>
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: prefetchedQuery')).toBeInTheDocument()
|
||||
expect(queryOpts.queryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should be able to recover from errors and try fetching again', async () => {
|
||||
const consoleMock = vi.spyOn(console, 'error')
|
||||
consoleMock.mockImplementation(() => undefined)
|
||||
const queryFn = vi.fn(() =>
|
||||
sleep(10).then(() => 'This is fine :dog: :fire:'),
|
||||
)
|
||||
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn,
|
||||
}
|
||||
|
||||
queryFn.mockImplementationOnce(() =>
|
||||
sleep(10).then(() => {
|
||||
throw new Error('Oops! Server error!')
|
||||
}),
|
||||
)
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseQuery(queryOpts)
|
||||
return <div>data: {String(state.data)}</div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { reset } = useQueryErrorResetBoundary()
|
||||
usePrefetchQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>Oops!</div>
|
||||
<button onClick={resetErrorBoundary}>Try again</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<React.Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
void queryClient.query(queryOpts).catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
queryFn.mockClear()
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
expect(rendered.getByText('Oops!')).toBeInTheDocument()
|
||||
fireEvent.click(rendered.getByText('Try again'))
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(
|
||||
rendered.getByText('data: This is fine :dog: :fire:'),
|
||||
).toBeInTheDocument()
|
||||
expect(queryOpts.queryFn).toHaveBeenCalledTimes(1)
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not create a suspense waterfall if prefetch is fired', async () => {
|
||||
const firstQueryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi.fn(() => sleep(10).then(() => 'Prefetch is nice!')),
|
||||
}
|
||||
|
||||
const secondQueryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi.fn(() => sleep(10).then(() => 'Prefetch is really nice!!')),
|
||||
}
|
||||
|
||||
const thirdQueryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi.fn(() =>
|
||||
sleep(10).then(() => 'Prefetch does not create waterfalls!!'),
|
||||
),
|
||||
}
|
||||
|
||||
const Fallback = vi.fn().mockImplementation(() => <div>Loading...</div>)
|
||||
|
||||
function FirstQuery({ children }: { children?: React.ReactNode }) {
|
||||
const state = useSuspenseQuery(firstQueryOpts)
|
||||
return (
|
||||
<div>
|
||||
<div>data: {String(state.data)}</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SecondQuery({ children }: { children?: React.ReactNode }) {
|
||||
const state = useSuspenseQuery(secondQueryOpts)
|
||||
return (
|
||||
<div>
|
||||
<div>data: {String(state.data)}</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ThirdQuery() {
|
||||
const state = useSuspenseQuery(thirdQueryOpts)
|
||||
return <div>data: {String(state.data)}</div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
usePrefetchQuery(firstQueryOpts)
|
||||
usePrefetchQuery(secondQueryOpts)
|
||||
usePrefetchQuery(thirdQueryOpts)
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<Fallback />}>
|
||||
<FirstQuery>
|
||||
<SecondQuery>
|
||||
<ThirdQuery />
|
||||
</SecondQuery>
|
||||
</FirstQuery>
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
expect(
|
||||
queryClient.getQueryState(firstQueryOpts.queryKey)?.fetchStatus,
|
||||
).toBe('fetching')
|
||||
expect(
|
||||
queryClient.getQueryState(secondQueryOpts.queryKey)?.fetchStatus,
|
||||
).toBe('fetching')
|
||||
expect(
|
||||
queryClient.getQueryState(thirdQueryOpts.queryKey)?.fetchStatus,
|
||||
).toBe('fetching')
|
||||
expect(rendered.getByText('Loading...')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: Prefetch is nice!')).toBeInTheDocument()
|
||||
expect(
|
||||
rendered.getByText('data: Prefetch is really nice!!'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
rendered.getByText('data: Prefetch does not create waterfalls!!'),
|
||||
).toBeInTheDocument()
|
||||
expect(Fallback).toHaveBeenCalledTimes(1)
|
||||
expect(firstQueryOpts.queryFn).toHaveBeenCalledTimes(1)
|
||||
expect(secondQueryOpts.queryFn).toHaveBeenCalledTimes(1)
|
||||
expect(thirdQueryOpts.queryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
1040
packages/react-query/src/__tests__/useQueries.test-d.tsx
Normal file
1040
packages/react-query/src/__tests__/useQueries.test-d.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1317
packages/react-query/src/__tests__/useQueries.test.tsx
Normal file
1317
packages/react-query/src/__tests__/useQueries.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
381
packages/react-query/src/__tests__/useQuery.test-d.tsx
Normal file
381
packages/react-query/src/__tests__/useQuery.test-d.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import { describe, expectTypeOf, it } from 'vitest'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { useQuery } from '../useQuery'
|
||||
import { queryOptions } from '../queryOptions'
|
||||
import type { OmitKeyof, QueryFunction, UseQueryOptions } from '..'
|
||||
|
||||
describe('useQuery', () => {
|
||||
const key = queryKey()
|
||||
|
||||
// unspecified query function should default to unknown
|
||||
const noQueryFn = useQuery({ queryKey: key })
|
||||
expectTypeOf(noQueryFn.data).toEqualTypeOf<unknown>()
|
||||
expectTypeOf(noQueryFn.error).toEqualTypeOf<Error | null>()
|
||||
|
||||
// it should infer the result type from the query function
|
||||
const fromQueryFn = useQuery({ queryKey: key, queryFn: () => 'test' })
|
||||
expectTypeOf(fromQueryFn.data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(fromQueryFn.error).toEqualTypeOf<Error | null>()
|
||||
expectTypeOf(fromQueryFn).not.toHaveProperty('promise')
|
||||
|
||||
// it should be possible to specify the result type
|
||||
const withResult = useQuery<string>({
|
||||
queryKey: key,
|
||||
queryFn: () => 'test',
|
||||
})
|
||||
expectTypeOf(withResult.data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(withResult.error).toEqualTypeOf<Error | null>()
|
||||
|
||||
// it should be possible to specify the error type
|
||||
const withError = useQuery<string, Error>({
|
||||
queryKey: key,
|
||||
queryFn: () => 'test',
|
||||
})
|
||||
expectTypeOf(withError.data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(withError.error).toEqualTypeOf<Error | null>()
|
||||
|
||||
// it should provide the result type in the configuration
|
||||
useQuery({
|
||||
queryKey: [key],
|
||||
queryFn: () => Promise.resolve(true),
|
||||
})
|
||||
|
||||
// it should be possible to specify a union type as result type
|
||||
const unionTypeSync = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => (Math.random() > 0.5 ? ('a' as const) : ('b' as const)),
|
||||
})
|
||||
expectTypeOf(unionTypeSync.data).toEqualTypeOf<'a' | 'b' | undefined>()
|
||||
const unionTypeAsync = useQuery<'a' | 'b'>({
|
||||
queryKey: key,
|
||||
queryFn: () => Promise.resolve(Math.random() > 0.5 ? 'a' : 'b'),
|
||||
})
|
||||
expectTypeOf(unionTypeAsync.data).toEqualTypeOf<'a' | 'b' | undefined>()
|
||||
|
||||
// should error when the query function result does not match with the specified type
|
||||
// @ts-expect-error
|
||||
useQuery<number>({ queryKey: key, queryFn: () => 'test' })
|
||||
|
||||
// it should infer the result type from a generic query function
|
||||
function queryFn<T = string>(): Promise<T> {
|
||||
return Promise.resolve({} as T)
|
||||
}
|
||||
|
||||
const fromGenericQueryFn = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => queryFn(),
|
||||
})
|
||||
expectTypeOf(fromGenericQueryFn.data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(fromGenericQueryFn.error).toEqualTypeOf<Error | null>()
|
||||
|
||||
type MyData = number
|
||||
type MyQueryKey = readonly ['my-data', number]
|
||||
|
||||
const getMyDataArrayKey: QueryFunction<MyData, MyQueryKey> = ({
|
||||
queryKey: [, n],
|
||||
}) => {
|
||||
return Promise.resolve(n + 42)
|
||||
}
|
||||
|
||||
useQuery({
|
||||
queryKey: ['my-data', 100],
|
||||
queryFn: getMyDataArrayKey,
|
||||
})
|
||||
|
||||
const getMyDataStringKey: QueryFunction<MyData, ['1']> = (context) => {
|
||||
expectTypeOf(context.queryKey).toEqualTypeOf<['1']>()
|
||||
return Promise.resolve(Number(context.queryKey[0]) + 42)
|
||||
}
|
||||
|
||||
useQuery({
|
||||
queryKey: ['1'],
|
||||
queryFn: getMyDataStringKey,
|
||||
})
|
||||
|
||||
// it should handle query-functions that return Promise<any>
|
||||
useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => fetch('return Promise<any>').then((resp) => resp.json()),
|
||||
})
|
||||
|
||||
// handles wrapped queries with custom fetcher passed as inline queryFn
|
||||
const useWrappedQuery = <
|
||||
TQueryKey extends [string, Record<string, unknown>?],
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData = TQueryFnData,
|
||||
>(
|
||||
qk: TQueryKey,
|
||||
fetcher: (
|
||||
obj: TQueryKey[1],
|
||||
token: string,
|
||||
// return type must be wrapped with TQueryFnReturn
|
||||
) => Promise<TQueryFnData>,
|
||||
options?: OmitKeyof<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
'queryKey' | 'queryFn' | 'initialData'
|
||||
>,
|
||||
) =>
|
||||
useQuery({
|
||||
queryKey: qk,
|
||||
queryFn: () => fetcher(qk[1], 'token'),
|
||||
...options,
|
||||
})
|
||||
const testQuery = useWrappedQuery([''], () => Promise.resolve('1'))
|
||||
expectTypeOf(testQuery.data).toEqualTypeOf<string | undefined>()
|
||||
|
||||
// handles wrapped queries with custom fetcher passed directly to useQuery
|
||||
const useWrappedFuncStyleQuery = <
|
||||
TQueryKey extends [string, Record<string, unknown>?],
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData = TQueryFnData,
|
||||
>(
|
||||
qk: TQueryKey,
|
||||
fetcher: () => Promise<TQueryFnData>,
|
||||
options?: OmitKeyof<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
'queryKey' | 'queryFn' | 'initialData'
|
||||
>,
|
||||
) => useQuery({ queryKey: qk, queryFn: fetcher, ...options })
|
||||
const testFuncStyle = useWrappedFuncStyleQuery([''], () =>
|
||||
Promise.resolve(true),
|
||||
)
|
||||
expectTypeOf(testFuncStyle.data).toEqualTypeOf<boolean | undefined>()
|
||||
|
||||
it('should return the correct states for a successful query', () => {
|
||||
const state = useQuery<string, Error>({
|
||||
queryKey: key,
|
||||
queryFn: () => Promise.resolve('test'),
|
||||
})
|
||||
|
||||
if (state.isPending) {
|
||||
expectTypeOf(state.data).toEqualTypeOf<undefined>()
|
||||
expectTypeOf(state.error).toEqualTypeOf<null>()
|
||||
return <span>pending</span>
|
||||
}
|
||||
|
||||
if (state.isLoadingError) {
|
||||
expectTypeOf(state.data).toEqualTypeOf<undefined>()
|
||||
expectTypeOf(state.error).toEqualTypeOf<Error>()
|
||||
return <span>{state.error.message}</span>
|
||||
}
|
||||
|
||||
expectTypeOf(state.data).toEqualTypeOf<string>()
|
||||
expectTypeOf(state.error).toEqualTypeOf<Error | null>()
|
||||
return <span>{state.data}</span>
|
||||
})
|
||||
|
||||
describe('initialData', () => {
|
||||
describe('Config object overload', () => {
|
||||
it('TData should always be defined when initialData is provided as an object', () => {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => ({ wow: true }),
|
||||
initialData: { wow: true },
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
|
||||
})
|
||||
|
||||
it('TData should be defined when passed through queryOptions', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: {
|
||||
wow: true,
|
||||
},
|
||||
})
|
||||
const { data } = useQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
|
||||
})
|
||||
|
||||
it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
})
|
||||
|
||||
const query = useQuery({
|
||||
...options,
|
||||
select: (data) => data > 1,
|
||||
})
|
||||
|
||||
expectTypeOf(query.data).toEqualTypeOf<boolean | undefined>()
|
||||
})
|
||||
|
||||
it('TData should always be defined when initialData is provided as a function which ALWAYS returns the data', () => {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: () => ({
|
||||
wow: true,
|
||||
}),
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
|
||||
})
|
||||
|
||||
it('TData should have undefined in the union when initialData is NOT provided', () => {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>()
|
||||
})
|
||||
|
||||
it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: () => undefined as { wow: boolean } | undefined,
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>()
|
||||
})
|
||||
|
||||
it('TData should be narrowed after an isSuccess check when initialData is provided as a function which can return undefined', () => {
|
||||
const { data, isSuccess } = useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: () => undefined as { wow: boolean } | undefined,
|
||||
})
|
||||
|
||||
if (isSuccess) {
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
|
||||
}
|
||||
})
|
||||
|
||||
it('should preserve discriminated-union narrowing', () => {
|
||||
type Result =
|
||||
| { type: 'first'; first: string }
|
||||
| { type: 'second'; second: string }
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: (): Result => ({ type: 'first', first: 'a' }),
|
||||
})
|
||||
|
||||
const second = query.data?.type === 'first' ? undefined : query.data
|
||||
|
||||
expectTypeOf(second).toEqualTypeOf<
|
||||
{ type: 'second'; second: string } | undefined
|
||||
>()
|
||||
})
|
||||
|
||||
it('data should not have undefined when initialData is provided', () => {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKey(),
|
||||
initialData: 42,
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom hook', () => {
|
||||
it('should allow custom hooks using UseQueryOptions', () => {
|
||||
type Data = string
|
||||
|
||||
const useCustomQuery = (
|
||||
options?: OmitKeyof<UseQueryOptions<Data>, 'queryKey' | 'queryFn'>,
|
||||
) => {
|
||||
return useQuery({
|
||||
...options,
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('data'),
|
||||
})
|
||||
}
|
||||
|
||||
const { data } = useCustomQuery()
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<Data | undefined>()
|
||||
})
|
||||
})
|
||||
|
||||
describe('structuralSharing', () => {
|
||||
it('should be able to use structuralSharing with unknown types', () => {
|
||||
// https://github.com/TanStack/query/issues/6525#issuecomment-1938411343
|
||||
useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 5,
|
||||
structuralSharing: (oldData, newData) => {
|
||||
expectTypeOf(oldData).toBeUnknown()
|
||||
expectTypeOf(newData).toBeUnknown()
|
||||
return newData
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('generic indexed access TData', () => {
|
||||
// https://github.com/TanStack/query/issues/9937
|
||||
it('should be assignable back to its source indexed type when passed to a generic function parameter', () => {
|
||||
enum DataType {
|
||||
Account = 'account',
|
||||
Product = 'product',
|
||||
}
|
||||
|
||||
interface Account {
|
||||
name: string
|
||||
}
|
||||
interface Product {
|
||||
code: string
|
||||
}
|
||||
|
||||
type DataTypeToEntity = {
|
||||
[DataType.Account]: Account
|
||||
[DataType.Product]: Product
|
||||
}
|
||||
|
||||
const getData = <TDataType extends DataType>(
|
||||
_dataType: TDataType,
|
||||
): Promise<DataTypeToEntity[TDataType]> =>
|
||||
Promise.resolve({} as DataTypeToEntity[TDataType])
|
||||
|
||||
const getLabel = <TDataType extends DataType>(
|
||||
_dataType: TDataType,
|
||||
_data: DataTypeToEntity[TDataType],
|
||||
) => 'test'
|
||||
|
||||
function Test<TDataType extends DataType>(props: {
|
||||
dataType: TDataType
|
||||
}) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['test'],
|
||||
queryFn: () => getData(props.dataType),
|
||||
})
|
||||
|
||||
// Regression guard: this call must compile. With the previous
|
||||
// hand-rolled NoInfer, `data` failed to flow back into the generic
|
||||
// indexed-access parameter `DataTypeToEntity[TDataType]`.
|
||||
return data ? getLabel(props.dataType, data) : null
|
||||
}
|
||||
|
||||
expectTypeOf(Test).toBeFunction()
|
||||
})
|
||||
})
|
||||
})
|
||||
7096
packages/react-query/src/__tests__/useQuery.test.tsx
Normal file
7096
packages/react-query/src/__tests__/useQuery.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
import { skipToken } from '@tanstack/query-core'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { useSuspenseInfiniteQuery } from '../useSuspenseInfiniteQuery'
|
||||
import type { InfiniteData } from '@tanstack/query-core'
|
||||
import type { UseSuspenseInfiniteQueryOptions } from '../types'
|
||||
|
||||
describe('useSuspenseInfiniteQuery', () => {
|
||||
it('should always have data defined', () => {
|
||||
const { data } = useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<number, unknown>>()
|
||||
})
|
||||
|
||||
it('should not allow skipToken in queryFn', () => {
|
||||
assertType(
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: skipToken,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not have pending status', () => {
|
||||
const { status } = useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
})
|
||||
|
||||
expectTypeOf(status).toEqualTypeOf<'error' | 'success'>()
|
||||
})
|
||||
|
||||
it('should not allow placeholderData, enabled or throwOnError props', () => {
|
||||
assertType(
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error TS2345
|
||||
placeholderData: 5,
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error TS2345
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error TS2345
|
||||
throwOnError: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should default TData of UseSuspenseInfiniteQueryOptions to InfiniteData<TQueryFnData>', () => {
|
||||
const options: UseSuspenseInfiniteQueryOptions<number, Error> = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
}
|
||||
const { data } = useSuspenseInfiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<number, unknown>>()
|
||||
})
|
||||
|
||||
it('should not return isPlaceholderData', () => {
|
||||
const query = useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
})
|
||||
|
||||
expectTypeOf(query).not.toHaveProperty('isPlaceholderData')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
skipToken,
|
||||
useSuspenseInfiniteQuery,
|
||||
} from '..'
|
||||
import { renderWithClient } from './utils'
|
||||
import type { InfiniteData, UseSuspenseInfiniteQueryResult } from '..'
|
||||
|
||||
describe('useSuspenseInfiniteQuery', () => {
|
||||
let queryCache: QueryCache
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryCache = new QueryCache()
|
||||
queryClient = new QueryClient({ queryCache })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should return the correct states for a successful infinite query', async () => {
|
||||
const key = queryKey()
|
||||
const states: Array<UseSuspenseInfiniteQueryResult<InfiniteData<number>>> =
|
||||
[]
|
||||
|
||||
function Page() {
|
||||
const [multiplier, setMultiplier] = React.useState(1)
|
||||
const state = useSuspenseInfiniteQuery({
|
||||
queryKey: [`${key}_${multiplier}`],
|
||||
queryFn: ({ pageParam }) =>
|
||||
sleep(10).then(() => pageParam * multiplier),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => lastPage + 1,
|
||||
})
|
||||
|
||||
states.push(state)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setMultiplier(2)}>next</button>
|
||||
data: {state.data?.pages.join(',')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<React.Suspense fallback="loading">
|
||||
<Page />
|
||||
</React.Suspense>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: 1')).toBeInTheDocument()
|
||||
|
||||
expect(states.length).toBe(1)
|
||||
expect(states[0]).toMatchObject({
|
||||
data: { pages: [1], pageParams: [1] },
|
||||
status: 'success',
|
||||
})
|
||||
|
||||
fireEvent.click(rendered.getByText('next'))
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: 2')).toBeInTheDocument()
|
||||
|
||||
expect(states.length).toBe(2)
|
||||
expect(states[1]).toMatchObject({
|
||||
data: { pages: [2], pageParams: [1] },
|
||||
status: 'success',
|
||||
})
|
||||
})
|
||||
|
||||
it('should log an error when skipToken is passed as queryFn', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: key,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
queryFn: Math.random() >= 0 ? skipToken : () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<React.Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
renderWithClient(queryClient, <App />)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'skipToken is not allowed for useSuspenseInfiniteQuery',
|
||||
)
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should log an error when skipToken is used in development environment', () => {
|
||||
const envCopy = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: key,
|
||||
queryFn: skipToken as any,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
queryClient,
|
||||
<React.Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</React.Suspense>,
|
||||
)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'skipToken is not allowed for useSuspenseInfiniteQuery',
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
process.env.NODE_ENV = envCopy
|
||||
})
|
||||
|
||||
it('should not log an error when skipToken is used in production environment', () => {
|
||||
const envCopy = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: key,
|
||||
queryFn: skipToken as any,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
queryClient,
|
||||
<React.Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</React.Suspense>,
|
||||
)
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
process.env.NODE_ENV = envCopy
|
||||
})
|
||||
|
||||
it('should still suspense if queryClient has placeholderData config', async () => {
|
||||
const key = queryKey()
|
||||
const queryClientWithPlaceholder = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
placeholderData: (previousData: any) => previousData,
|
||||
},
|
||||
},
|
||||
})
|
||||
const states: Array<UseSuspenseInfiniteQueryResult<InfiniteData<number>>> =
|
||||
[]
|
||||
|
||||
let count = 0
|
||||
|
||||
function Page() {
|
||||
const [stateKey, setStateKey] = React.useState(key)
|
||||
|
||||
const state = useSuspenseInfiniteQuery({
|
||||
queryKey: stateKey,
|
||||
queryFn: () => sleep(10).then(() => ++count),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => undefined,
|
||||
})
|
||||
|
||||
states.push(state)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button aria-label="toggle" onClick={() => setStateKey(queryKey())} />
|
||||
data: {String(state.data?.pages.join(','))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClientWithPlaceholder,
|
||||
<React.Suspense fallback="loading">
|
||||
<Page />
|
||||
</React.Suspense>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: 1')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByLabelText('toggle'))
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(rendered.getByText('data: 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
426
packages/react-query/src/__tests__/useSuspenseQueries.test-d.tsx
Normal file
426
packages/react-query/src/__tests__/useSuspenseQueries.test-d.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { skipToken, useSuspenseQueries } from '..'
|
||||
import { queryOptions } from '../queryOptions'
|
||||
import type { OmitKeyof } from '..'
|
||||
import type { UseQueryOptions, UseSuspenseQueryResult } from '../types'
|
||||
|
||||
describe('UseSuspenseQueries config object overload', () => {
|
||||
it('TData should always be defined', () => {
|
||||
const query1 = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: {
|
||||
wow: false,
|
||||
},
|
||||
}
|
||||
|
||||
const query2 = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 'Query Data',
|
||||
}
|
||||
|
||||
const queryResults = useSuspenseQueries({ queries: [query1, query2] })
|
||||
|
||||
const query1Data = queryResults[0].data
|
||||
const query2Data = queryResults[1].data
|
||||
|
||||
expectTypeOf(query1Data).toEqualTypeOf<{ wow: boolean }>()
|
||||
expectTypeOf(query2Data).toEqualTypeOf<string>()
|
||||
})
|
||||
|
||||
it('TData should be defined when passed through queryOptions', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
const queryResults = useSuspenseQueries({ queries: [options] })
|
||||
|
||||
const data = queryResults[0].data
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
|
||||
})
|
||||
|
||||
it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => {
|
||||
const query1 = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
select: (data) => data > 1,
|
||||
})
|
||||
|
||||
const query2 = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
select: (data: number) => data > 1,
|
||||
}
|
||||
|
||||
const queryResults = useSuspenseQueries({ queries: [query1, query2] })
|
||||
const query1Data = queryResults[0].data
|
||||
const query2Data = queryResults[1].data
|
||||
|
||||
expectTypeOf(query1Data).toEqualTypeOf<boolean>()
|
||||
expectTypeOf(query2Data).toEqualTypeOf<boolean>()
|
||||
})
|
||||
|
||||
it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => {
|
||||
const queryResults = useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: () => undefined as { wow: boolean } | undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const data = queryResults[0].data
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
|
||||
})
|
||||
|
||||
it('should not allow skipToken in queryFn', () => {
|
||||
assertType(
|
||||
useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: skipToken,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('TData should have correct type when conditional skipToken is passed', () => {
|
||||
const queryResults = useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const firstResult = queryResults[0]
|
||||
|
||||
expectTypeOf(firstResult).toEqualTypeOf<
|
||||
UseSuspenseQueryResult<number, Error>
|
||||
>()
|
||||
expectTypeOf(firstResult.data).toEqualTypeOf<number>()
|
||||
})
|
||||
|
||||
describe('custom hook', () => {
|
||||
it('should allow custom hooks using UseQueryOptions', () => {
|
||||
type Data = string
|
||||
|
||||
const useCustomQueries = (
|
||||
options?: OmitKeyof<UseQueryOptions<Data>, 'queryKey' | 'queryFn'>,
|
||||
) => {
|
||||
return useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
...options,
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('data'),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const queryResults = useCustomQueries()
|
||||
const data = queryResults[0].data
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<Data>()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return correct data for dynamic queries with mixed result types', () => {
|
||||
const Queries1 = {
|
||||
get: () =>
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
}),
|
||||
}
|
||||
const Queries2 = {
|
||||
get: () =>
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(true),
|
||||
}),
|
||||
}
|
||||
|
||||
const queries1List = [1, 2, 3].map(() => ({ ...Queries1.get() }))
|
||||
const result = useSuspenseQueries({
|
||||
queries: [
|
||||
...queries1List,
|
||||
{
|
||||
...Queries2.get(),
|
||||
select(data: boolean) {
|
||||
return data
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expectTypeOf(result).toEqualTypeOf<
|
||||
[
|
||||
...Array<UseSuspenseQueryResult<number, Error>>,
|
||||
UseSuspenseQueryResult<boolean, Error>,
|
||||
]
|
||||
>()
|
||||
})
|
||||
|
||||
it('queryOptions with initialData works on useSuspenseQueries', () => {
|
||||
const query1 = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 'Query Data',
|
||||
initialData: 'initial data',
|
||||
})
|
||||
|
||||
const queryResults = useSuspenseQueries({ queries: [query1] })
|
||||
const query1Data = queryResults[0].data
|
||||
|
||||
expectTypeOf(query1Data).toEqualTypeOf<string>()
|
||||
})
|
||||
|
||||
it('queryOptions with skipToken in queryFn should not work on useSuspenseQueries', () => {
|
||||
assertType(
|
||||
useSuspenseQueries({
|
||||
queries: [
|
||||
// @ts-expect-error
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useSuspenseQueries({
|
||||
queries: [
|
||||
// @ts-expect-error
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
initialData: 5,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not show type error when using rest queryOptions', () => {
|
||||
assertType(
|
||||
useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
...queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 'Query Data',
|
||||
}),
|
||||
select(data: string) {
|
||||
return data
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe('select', () => {
|
||||
// Inferring the `select` argument of an *inline* query object from its
|
||||
// sibling `queryFn` is a known TypeScript limitation, because
|
||||
// `useSuspenseQueries` infers its array generic from the argument itself.
|
||||
// The two supported workarounds are to annotate the `select` parameter, or
|
||||
// to define the query with the `queryOptions` helper.
|
||||
// https://github.com/TanStack/query/issues/6556
|
||||
|
||||
describe('without queryOptions (inline query object)', () => {
|
||||
it('leaves the select argument as `unknown` without an annotation', () => {
|
||||
useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toBeUnknown()
|
||||
// @ts-expect-error `data` is `unknown`, not the expected `number`
|
||||
return data.toFixed()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('infers the result when the select parameter is annotated', () => {
|
||||
const queryResults = useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
select: (data: number) => data.toFixed(),
|
||||
},
|
||||
],
|
||||
})
|
||||
expectTypeOf(queryResults[0].data).toEqualTypeOf<string>()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with queryOptions passed directly', () => {
|
||||
it('without select, infers the queryFn data as the result', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
})
|
||||
const queryResults = useSuspenseQueries({ queries: [options] })
|
||||
expectTypeOf(queryResults[0].data).toEqualTypeOf<number>()
|
||||
})
|
||||
|
||||
it('with select, infers the select argument and the result', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
return data.toFixed()
|
||||
},
|
||||
})
|
||||
const queryResults = useSuspenseQueries({ queries: [options] })
|
||||
expectTypeOf(queryResults[0].data).toEqualTypeOf<string>()
|
||||
})
|
||||
|
||||
it('infers select when a base queryOptions is re-wrapped with queryOptions', () => {
|
||||
const baseOptions = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
})
|
||||
const queryResults = useSuspenseQueries({
|
||||
queries: [
|
||||
queryOptions({
|
||||
...baseOptions,
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
return data.toFixed()
|
||||
},
|
||||
}),
|
||||
baseOptions,
|
||||
],
|
||||
})
|
||||
expectTypeOf(queryResults[0].data).toEqualTypeOf<string>()
|
||||
expectTypeOf(queryResults[1].data).toEqualTypeOf<number>()
|
||||
})
|
||||
|
||||
it('infers an overriding select when a queryOptions with a select is re-wrapped with queryOptions', () => {
|
||||
const baseOptions = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
select: (data) => data + 1,
|
||||
})
|
||||
const queryResults = useSuspenseQueries({
|
||||
queries: [
|
||||
queryOptions({
|
||||
...baseOptions,
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
return data.toFixed()
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
expectTypeOf(queryResults[0].data).toEqualTypeOf<string>()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with queryOptions spread into an inline query object', () => {
|
||||
it('without select in the factory, leaves an unannotated select untyped', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
})
|
||||
useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
...options,
|
||||
// @ts-expect-error Without an annotation the inline `select` parameter `data` implicitly has type `any`
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toBeAny()
|
||||
return data
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('without select in the factory, an annotated select compiles', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
})
|
||||
const queryResults = useSuspenseQueries({
|
||||
queries: [{ ...options, select: (data: number) => data.toFixed() }],
|
||||
})
|
||||
expectTypeOf(queryResults[0].data).toEqualTypeOf<string>()
|
||||
})
|
||||
|
||||
it('with select in the factory, leaves an unannotated overriding select untyped', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
select: (data) => data + 1,
|
||||
})
|
||||
useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
...options,
|
||||
// @ts-expect-error Without an annotation the inline `select` parameter `data` implicitly has type `any`
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toBeAny()
|
||||
return data
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('with select in the factory, an annotated overriding select compiles', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
select: (data) => data + 1,
|
||||
})
|
||||
const queryResults = useSuspenseQueries({
|
||||
queries: [{ ...options, select: (data: number) => data.toFixed() }],
|
||||
})
|
||||
expectTypeOf(queryResults[0].data).toEqualTypeOf<string>()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
1365
packages/react-query/src/__tests__/useSuspenseQueries.test.tsx
Normal file
1365
packages/react-query/src/__tests__/useSuspenseQueries.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
import { skipToken } from '@tanstack/query-core'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { useSuspenseQuery } from '../useSuspenseQuery'
|
||||
|
||||
describe('useSuspenseQuery', () => {
|
||||
it('should always have data defined', () => {
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
|
||||
it('should not have pending status', () => {
|
||||
const { status } = useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
expectTypeOf(status).toEqualTypeOf<'error' | 'success'>()
|
||||
})
|
||||
|
||||
it('should not allow skipToken in queryFn', () => {
|
||||
assertType(
|
||||
useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: skipToken,
|
||||
}),
|
||||
)
|
||||
assertType(
|
||||
useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not allow placeholderData, enabled or throwOnError props', () => {
|
||||
assertType(
|
||||
useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error TS2345
|
||||
placeholderData: 5,
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
assertType(
|
||||
useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error TS2345
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
assertType(
|
||||
useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error TS2345
|
||||
throwOnError: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not return isPlaceholderData', () => {
|
||||
expectTypeOf(
|
||||
useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
}),
|
||||
).not.toHaveProperty('isPlaceholderData')
|
||||
})
|
||||
|
||||
it('should type-narrow the error field', () => {
|
||||
const query = useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
if (query.status === 'error') {
|
||||
expectTypeOf(query.error).toEqualTypeOf<Error>()
|
||||
}
|
||||
})
|
||||
})
|
||||
1135
packages/react-query/src/__tests__/useSuspenseQuery.test.tsx
Normal file
1135
packages/react-query/src/__tests__/useSuspenseQuery.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
64
packages/react-query/src/__tests__/utils.tsx
Normal file
64
packages/react-query/src/__tests__/utils.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { vi } from 'vitest'
|
||||
import * as React from 'react'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { environmentManager, isServer } from '@tanstack/query-core'
|
||||
import { QueryClientProvider, onlineManager } from '..'
|
||||
import type { QueryClient } from '..'
|
||||
import type { MockInstance } from 'vitest'
|
||||
|
||||
export function renderWithClient(
|
||||
client: QueryClient,
|
||||
ui: React.ReactElement,
|
||||
): ReturnType<typeof render> {
|
||||
const { rerender, ...result } = render(
|
||||
<QueryClientProvider client={client}>{ui}</QueryClientProvider>,
|
||||
)
|
||||
return {
|
||||
...result,
|
||||
rerender: (rerenderUi: React.ReactElement) =>
|
||||
rerender(
|
||||
<QueryClientProvider client={client}>{rerenderUi}</QueryClientProvider>,
|
||||
),
|
||||
} as any
|
||||
}
|
||||
|
||||
export function Blink({
|
||||
duration,
|
||||
children,
|
||||
}: {
|
||||
duration: number
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [shouldShow, setShouldShow] = React.useState<boolean>(true)
|
||||
|
||||
React.useEffect(() => {
|
||||
setShouldShow(true)
|
||||
const timeout = setActTimeout(() => setShouldShow(false), duration)
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [duration, children])
|
||||
|
||||
return shouldShow ? <>{children}</> : <>off</>
|
||||
}
|
||||
|
||||
export function mockOnlineManagerIsOnline(
|
||||
value: boolean,
|
||||
): MockInstance<() => boolean> {
|
||||
return vi.spyOn(onlineManager, 'isOnline').mockReturnValue(value)
|
||||
}
|
||||
|
||||
export function setActTimeout(fn: () => void, ms?: number) {
|
||||
return setTimeout(() => {
|
||||
act(() => {
|
||||
fn()
|
||||
})
|
||||
}, ms)
|
||||
}
|
||||
|
||||
export function setIsServer(value: boolean) {
|
||||
environmentManager.setIsServer(() => value)
|
||||
return () => {
|
||||
environmentManager.setIsServer(() => isServer)
|
||||
}
|
||||
}
|
||||
78
packages/react-query/src/errorBoundaryUtils.ts
Normal file
78
packages/react-query/src/errorBoundaryUtils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
import { shouldThrowError } from '@tanstack/query-core'
|
||||
import type {
|
||||
DefaultedQueryObserverOptions,
|
||||
Query,
|
||||
QueryKey,
|
||||
QueryObserverResult,
|
||||
ThrowOnError,
|
||||
} from '@tanstack/query-core'
|
||||
import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary'
|
||||
|
||||
export const ensurePreventErrorBoundaryRetry = <
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey extends QueryKey,
|
||||
>(
|
||||
options: DefaultedQueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
errorResetBoundary: QueryErrorResetBoundaryValue,
|
||||
query: Query<TQueryFnData, TError, TQueryData, TQueryKey> | undefined,
|
||||
) => {
|
||||
const throwOnError =
|
||||
query?.state.error && typeof options.throwOnError === 'function'
|
||||
? shouldThrowError(options.throwOnError, [query.state.error, query])
|
||||
: options.throwOnError
|
||||
|
||||
if (options.suspense || throwOnError) {
|
||||
// Prevent retrying failed query if the error boundary has not been reset yet
|
||||
if (!errorResetBoundary.isReset()) {
|
||||
options.retryOnMount = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useClearResetErrorBoundary = (
|
||||
errorResetBoundary: QueryErrorResetBoundaryValue,
|
||||
) => {
|
||||
React.useEffect(() => {
|
||||
errorResetBoundary.clearReset()
|
||||
}, [errorResetBoundary])
|
||||
}
|
||||
|
||||
export const getHasError = <
|
||||
TData,
|
||||
TError,
|
||||
TQueryFnData,
|
||||
TQueryData,
|
||||
TQueryKey extends QueryKey,
|
||||
>({
|
||||
result,
|
||||
errorResetBoundary,
|
||||
throwOnError,
|
||||
query,
|
||||
suspense,
|
||||
}: {
|
||||
result: QueryObserverResult<TData, TError>
|
||||
errorResetBoundary: QueryErrorResetBoundaryValue
|
||||
throwOnError: ThrowOnError<TQueryFnData, TError, TQueryData, TQueryKey>
|
||||
query: Query<TQueryFnData, TError, TQueryData, TQueryKey> | undefined
|
||||
suspense: boolean | undefined
|
||||
}) => {
|
||||
return (
|
||||
result.isError &&
|
||||
!errorResetBoundary.isReset() &&
|
||||
!result.isFetching &&
|
||||
query &&
|
||||
((suspense && result.data === undefined) ||
|
||||
shouldThrowError(throwOnError, [result.error, query]))
|
||||
)
|
||||
}
|
||||
56
packages/react-query/src/index.ts
Normal file
56
packages/react-query/src/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
// Re-export core
|
||||
export * from '@tanstack/query-core'
|
||||
|
||||
// React Query
|
||||
export type * from './types'
|
||||
export { useQueries } from './useQueries'
|
||||
export type { QueriesResults, QueriesOptions } from './useQueries'
|
||||
export { useQuery } from './useQuery'
|
||||
export { useSuspenseQuery } from './useSuspenseQuery'
|
||||
export { useSuspenseInfiniteQuery } from './useSuspenseInfiniteQuery'
|
||||
export { useSuspenseQueries } from './useSuspenseQueries'
|
||||
export type {
|
||||
SuspenseQueriesResults,
|
||||
SuspenseQueriesOptions,
|
||||
} from './useSuspenseQueries'
|
||||
export { usePrefetchQuery } from './usePrefetchQuery'
|
||||
export { usePrefetchInfiniteQuery } from './usePrefetchInfiniteQuery'
|
||||
export { queryOptions } from './queryOptions'
|
||||
export type {
|
||||
DefinedInitialDataOptions,
|
||||
UndefinedInitialDataOptions,
|
||||
UnusedSkipTokenOptions,
|
||||
} from './queryOptions'
|
||||
export { infiniteQueryOptions } from './infiniteQueryOptions'
|
||||
export type {
|
||||
DefinedInitialDataInfiniteOptions,
|
||||
UndefinedInitialDataInfiniteOptions,
|
||||
UnusedSkipTokenInfiniteOptions,
|
||||
} from './infiniteQueryOptions'
|
||||
export {
|
||||
QueryClientContext,
|
||||
QueryClientProvider,
|
||||
useQueryClient,
|
||||
} from './QueryClientProvider'
|
||||
export type { QueryClientProviderProps } from './QueryClientProvider'
|
||||
export type { QueryErrorResetBoundaryProps } from './QueryErrorResetBoundary'
|
||||
export { HydrationBoundary } from './HydrationBoundary'
|
||||
export type { HydrationBoundaryProps } from './HydrationBoundary'
|
||||
export type {
|
||||
QueryErrorClearResetFunction,
|
||||
QueryErrorIsResetFunction,
|
||||
QueryErrorResetBoundaryFunction,
|
||||
QueryErrorResetFunction,
|
||||
} from './QueryErrorResetBoundary'
|
||||
export {
|
||||
QueryErrorResetBoundary,
|
||||
useQueryErrorResetBoundary,
|
||||
} from './QueryErrorResetBoundary'
|
||||
export { useIsFetching } from './useIsFetching'
|
||||
export { useIsMutating, useMutationState } from './useMutationState'
|
||||
export { useMutation } from './useMutation'
|
||||
export { mutationOptions } from './mutationOptions'
|
||||
export { useInfiniteQuery } from './useInfiniteQuery'
|
||||
export { useIsRestoring, IsRestoringProvider } from './IsRestoringProvider'
|
||||
319
packages/react-query/src/infiniteQueryOptions.ts
Normal file
319
packages/react-query/src/infiniteQueryOptions.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import type {
|
||||
DefaultError,
|
||||
InfiniteData,
|
||||
InitialDataFunction,
|
||||
NonUndefinedGuard,
|
||||
OmitKeyof,
|
||||
QueryKey,
|
||||
QueryKeyWithDataTag,
|
||||
SkipToken,
|
||||
} from '@tanstack/query-core'
|
||||
import type { UseInfiniteQueryOptions } from './types'
|
||||
|
||||
/**
|
||||
* The options accepted by the `infiniteQueryOptions` overload selected when no `initialData` is set — `data`
|
||||
* may be `undefined` while the query is `pending`.
|
||||
*
|
||||
* @template TQueryFnData - The type of a single page, as your `queryFn` resolves it.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs — defaults to `InfiniteData<TQueryFnData>`,
|
||||
* the shape of all fetched pages plus their page params.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
* @template TPageParam - The type of the parameter passed to `queryFn` to fetch a given page.
|
||||
*/
|
||||
export type UndefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
> = UseInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
> & {
|
||||
/**
|
||||
* If set, this value will be used as the initial data for the query cache (as long as the query hasn't been
|
||||
* created or cached yet). If set to a function, the function will be called **once** during the shared/root
|
||||
* query initialization, and be expected to synchronously return the initial data. Initial data is
|
||||
* considered stale by default unless a `staleTime` has been set. `initialData` **is persisted** to the
|
||||
* cache.
|
||||
*/
|
||||
initialData?:
|
||||
| undefined
|
||||
| NonUndefinedGuard<InfiniteData<TQueryFnData, TPageParam>>
|
||||
| InitialDataFunction<
|
||||
NonUndefinedGuard<InfiniteData<TQueryFnData, TPageParam>>
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* The options accepted by the `infiniteQueryOptions` overload selected when no `initialData` is set and
|
||||
* `queryFn` is not `skipToken` — same as {@link UndefinedInitialDataInfiniteOptions}, but `queryFn` may not be
|
||||
* `skipToken`.
|
||||
*
|
||||
* @template TQueryFnData - The type of a single page, as your `queryFn` resolves it.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs — defaults to `InfiniteData<TQueryFnData>`,
|
||||
* the shape of all fetched pages plus their page params.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
* @template TPageParam - The type of the parameter passed to `queryFn` to fetch a given page.
|
||||
*/
|
||||
export type UnusedSkipTokenInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
> = OmitKeyof<
|
||||
UseInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>,
|
||||
'queryFn'
|
||||
> & {
|
||||
/**
|
||||
* `skipToken` is not allowed as a value here — this overload is selected when no `initialData` is set. If
|
||||
* you don't intend to run the query yet, set `enabled: false` — omitting `queryFn` alone still triggers a
|
||||
* fetch that fails with "Missing queryFn" unless `enabled` is `false` or a default query function has been
|
||||
* defined. A default query function only supplies `queryFn`; it doesn't defer the fetch on its own.
|
||||
*/
|
||||
queryFn?: Exclude<
|
||||
UseInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>['queryFn'],
|
||||
SkipToken | undefined
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* The options accepted by the `infiniteQueryOptions` overload selected when `initialData` is set — `data` is
|
||||
* never `undefined`.
|
||||
*
|
||||
* @template TQueryFnData - The type of a single page, as your `queryFn` resolves it.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs — defaults to `InfiniteData<TQueryFnData>`,
|
||||
* the shape of all fetched pages plus their page params.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
* @template TPageParam - The type of the parameter passed to `queryFn` to fetch a given page.
|
||||
*/
|
||||
export type DefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
> = UseInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
> & {
|
||||
/**
|
||||
* If set, this value will be used as the initial data for the query cache (as long as the query hasn't been
|
||||
* created or cached yet). If set to a function, the function will be called **once** during the shared/root
|
||||
* query initialization, and be expected to synchronously return the initial data. Initial data is
|
||||
* considered stale by default unless a `staleTime` has been set. `initialData` **is persisted** to the
|
||||
* cache.
|
||||
*/
|
||||
initialData:
|
||||
| NonUndefinedGuard<InfiniteData<TQueryFnData, TPageParam>>
|
||||
| (() => NonUndefinedGuard<InfiniteData<TQueryFnData, TPageParam>>)
|
||||
| undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `infiniteQueryOptions` that you can also pass to `useInfiniteQuery`.
|
||||
* These options can be shared across hooks and imperative APIs such as `queryClient.infiniteQuery`.
|
||||
* `options.queryKey` is required and is the query key to generate options for.
|
||||
*
|
||||
* This overload is selected when `initialData` is set.
|
||||
*
|
||||
* @see {@link useInfiniteQuery} to run an infinite query with these options.
|
||||
* @param options - The {@link DefinedInitialDataInfiniteOptions} to use — everything you can pass to `useInfiniteQuery`, with `initialData` set.
|
||||
* @returns The same options object, typed so that `queryKey` carries the inferred data type.
|
||||
* @remarks See {@link useInfiniteQuery} for examples that fetch further pages, from a button click or
|
||||
* automatically as the user scrolls.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* export const projectsOptions = infiniteQueryOptions({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* initialData: { pages: [], pageParams: [] },
|
||||
* })
|
||||
*
|
||||
* function Projects() {
|
||||
* // `data` is never `undefined`, thanks to `initialData` — even if a refetch fails, so the
|
||||
* // list stays visible alongside the error.
|
||||
* const { data, isError, error } = useInfiniteQuery(projectsOptions)
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {isError ? <span>Error: {error.message}</span> : null}
|
||||
* <ul>
|
||||
* {data.pages.map((page) => page.projects.map((p) => <li key={p.id}>{p.name}</li>))}
|
||||
* </ul>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function infiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: DefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
): DefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
> &
|
||||
QueryKeyWithDataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `infiniteQueryOptions` that you can also pass to `useInfiniteQuery`.
|
||||
* These options can be shared across hooks and imperative APIs such as `queryClient.infiniteQuery`.
|
||||
* `options.queryKey` is required and is the query key to generate options for.
|
||||
*
|
||||
* @returns The same options object, typed so that `queryKey` carries the inferred data type.
|
||||
* @remarks See {@link useInfiniteQuery} for examples that fetch further pages, from a button click or
|
||||
* automatically as the user scrolls.
|
||||
*
|
||||
* @example
|
||||
* A parameterized factory, so the same options object can be reused per `postId`:
|
||||
* ```tsx
|
||||
* import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* export const commentsOptions = (postId: string) =>
|
||||
* infiniteQueryOptions({
|
||||
* queryKey: ['post', postId, 'comments'],
|
||||
* queryFn: ({ pageParam }) => fetchComments(postId, pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* function Comments({ postId }: { postId: string }) {
|
||||
* const { data, isPending, isError, error } = useInfiniteQuery(commentsOptions(postId))
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {data.pages.map((page) => page.comments.map((c) => <li key={c.id}>{c.text}</li>))}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see {@link useInfiniteQuery} to run an infinite query with these options.
|
||||
* @param options - The {@link UnusedSkipTokenInfiniteOptions} to use — everything you can pass to `useInfiniteQuery`.
|
||||
*/
|
||||
export function infiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: UnusedSkipTokenInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
): UnusedSkipTokenInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
> &
|
||||
QueryKeyWithDataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `infiniteQueryOptions` that you can also pass to `useInfiniteQuery`.
|
||||
* These options can be shared across hooks and imperative APIs such as `queryClient.infiniteQuery`.
|
||||
* `options.queryKey` is required and is the query key to generate options for.
|
||||
*
|
||||
* @returns The same options object, typed so that `queryKey` carries the inferred data type.
|
||||
* @remarks See {@link useInfiniteQuery} for examples that fetch further pages (from a button click or
|
||||
* automatically as the user scrolls) and that use `skipToken` to disable the query until `postId` is set.
|
||||
*
|
||||
* @example
|
||||
* A parameterized factory, so the same options object can be reused per `postId`:
|
||||
* ```tsx
|
||||
* import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* export const commentsOptions = (postId: string) =>
|
||||
* infiniteQueryOptions({
|
||||
* queryKey: ['post', postId, 'comments'],
|
||||
* queryFn: ({ pageParam }) => fetchComments(postId, pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* function Comments({ postId }: { postId: string }) {
|
||||
* const { data, isPending, isError, error } = useInfiniteQuery(commentsOptions(postId))
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {data.pages.map((page) => page.comments.map((c) => <li key={c.id}>{c.text}</li>))}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see {@link useInfiniteQuery} to run an infinite query with these options.
|
||||
* @param options - The {@link UndefinedInitialDataInfiniteOptions} to use — everything you can pass to `useInfiniteQuery`.
|
||||
*/
|
||||
export function infiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: UndefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
): UndefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
> &
|
||||
QueryKeyWithDataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>
|
||||
|
||||
export function infiniteQueryOptions(options: unknown) {
|
||||
return options
|
||||
}
|
||||
96
packages/react-query/src/mutationOptions.ts
Normal file
96
packages/react-query/src/mutationOptions.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { DefaultError, WithRequired } from '@tanstack/query-core'
|
||||
import type { UseMutationOptions } from './types'
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `mutationOptions` that you can also pass to `useMutation`. A
|
||||
* `mutationKey` is required on this overload so the mutation can be looked up later, e.g. with
|
||||
* `useMutationState`.
|
||||
*
|
||||
* @see {@link useMutation} to run the mutation these options describe.
|
||||
* @param options - The mutation options to use, identical to what you'd pass to `useMutation`, with a
|
||||
* required `mutationKey`.
|
||||
* @returns The same options object, unchanged.
|
||||
*
|
||||
* @example
|
||||
* Looking the mutation up elsewhere via its `mutationKey`, e.g. for a global "saving…" indicator:
|
||||
* ```tsx
|
||||
* import { mutationOptions, useMutationState } from '@tanstack/react-query'
|
||||
*
|
||||
* const createPostOptions = mutationOptions({
|
||||
* mutationKey: ['posts', 'create'],
|
||||
* mutationFn: createPost,
|
||||
* })
|
||||
*
|
||||
* function SavingIndicator() {
|
||||
* const isCreatingPost = useMutationState({
|
||||
* filters: { mutationKey: createPostOptions.mutationKey, status: 'pending' },
|
||||
* }).length > 0
|
||||
*
|
||||
* return isCreatingPost ? <span>Saving…</span> : null
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function mutationOptions<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
>(
|
||||
options: WithRequired<
|
||||
UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
'mutationKey'
|
||||
>,
|
||||
): WithRequired<
|
||||
UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
'mutationKey'
|
||||
>
|
||||
/**
|
||||
* You can generally pass everything to `mutationOptions` that you can also pass to `useMutation`. No
|
||||
* `mutationKey` is required on this overload — use this when you don't need to target the mutation via a
|
||||
* `mutationKey` filter later (e.g. with `useMutationState`); it can still be observed through other filters,
|
||||
* such as `status`.
|
||||
*
|
||||
* @see {@link useMutation} to run the mutation these options describe.
|
||||
* @param options - The mutation options to use, identical to what you'd pass to `useMutation`, without a
|
||||
* `mutationKey`.
|
||||
* @returns The same options object, unchanged.
|
||||
* @remarks See the other overload's example for looking a mutation up via `useMutationState`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { mutationOptions, useMutation } from '@tanstack/react-query'
|
||||
*
|
||||
* export const createPostOptions = mutationOptions({
|
||||
* mutationFn: createPost,
|
||||
* })
|
||||
*
|
||||
* function CreatePost() {
|
||||
* const mutation = useMutation(createPostOptions)
|
||||
* return <button onClick={() => mutation.mutate({ title: 'Hello' })}>Create</button>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function mutationOptions<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
>(
|
||||
options: Omit<
|
||||
UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
'mutationKey'
|
||||
>,
|
||||
): Omit<
|
||||
UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
'mutationKey'
|
||||
>
|
||||
export function mutationOptions<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
>(
|
||||
options: UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
): UseMutationOptions<TData, TError, TVariables, TOnMutateResult> {
|
||||
return options
|
||||
}
|
||||
259
packages/react-query/src/queryOptions.ts
Normal file
259
packages/react-query/src/queryOptions.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import type {
|
||||
DefaultError,
|
||||
InitialDataFunction,
|
||||
NonUndefinedGuard,
|
||||
OmitKeyof,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
QueryKeyWithDataTag,
|
||||
SkipToken,
|
||||
} from '@tanstack/query-core'
|
||||
import type { UseQueryOptions } from './types'
|
||||
|
||||
/**
|
||||
* The options accepted by the `queryOptions` overload selected when no `initialData` is set — `data` may be
|
||||
* `undefined` while the query is `pending`.
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export type UndefinedInitialDataOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> & {
|
||||
/**
|
||||
* If set, this value will be used as the initial data for the query cache (as long as the query hasn't been
|
||||
* created or cached yet). If set to a function, the function will be called **once** during the shared/root
|
||||
* query initialization, and be expected to synchronously return the initial data. Initial data is
|
||||
* considered stale by default unless a `staleTime` has been set. `initialData` **is persisted** to the
|
||||
* cache.
|
||||
*/
|
||||
initialData?:
|
||||
| undefined
|
||||
| InitialDataFunction<NonUndefinedGuard<TQueryFnData>>
|
||||
| NonUndefinedGuard<TQueryFnData>
|
||||
}
|
||||
|
||||
/**
|
||||
* The options accepted by the `queryOptions` overload selected when no `initialData` is set and `queryFn` is
|
||||
* not `skipToken` — same as {@link UndefinedInitialDataOptions}, but `queryFn` may not be `skipToken`.
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export type UnusedSkipTokenOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = OmitKeyof<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
'queryFn'
|
||||
> & {
|
||||
/**
|
||||
* `skipToken` is not allowed as a value here — this overload is selected when no `initialData` is set. If
|
||||
* you don't intend to run the query yet, set `enabled: false` — omitting `queryFn` alone still triggers a
|
||||
* fetch that fails with "Missing queryFn" unless `enabled` is `false` or a default query function has been
|
||||
* defined. A default query function only supplies `queryFn`; it doesn't defer the fetch on its own.
|
||||
*/
|
||||
queryFn?: Exclude<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>['queryFn'],
|
||||
SkipToken | undefined
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* The options accepted by the `queryOptions` overload selected when `initialData` is set — `data` is never
|
||||
* `undefined`.
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export type DefinedInitialDataOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = Omit<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, 'queryFn'> & {
|
||||
/**
|
||||
* If set, this value will be used as the initial data for the query cache (as long as the query hasn't been
|
||||
* created or cached yet). If set to a function, the function will be called **once** during the shared/root
|
||||
* query initialization, and be expected to synchronously return the initial data. Initial data is
|
||||
* considered stale by default unless a `staleTime` has been set. `initialData` **is persisted** to the
|
||||
* cache.
|
||||
*/
|
||||
initialData:
|
||||
| NonUndefinedGuard<TQueryFnData>
|
||||
| (() => NonUndefinedGuard<TQueryFnData>)
|
||||
/**
|
||||
* Optional here, but omitting it is only safe when no fetch will be attempted — for example with
|
||||
* `enabled: false`, or when a default query function has been defined. Otherwise, an enabled query with no
|
||||
* `queryFn` still tries to fetch and fails with a "Missing queryFn" error; `initialData` does not prevent this.
|
||||
*/
|
||||
queryFn?: QueryFunction<TQueryFnData, TQueryKey>
|
||||
}
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `queryOptions` that you can also pass to `useQuery`. These options can
|
||||
* be shared across hooks and imperative APIs such as `queryClient.query`. `options.queryKey` is required and
|
||||
* is the query key to generate options for.
|
||||
*
|
||||
* This overload is selected when `initialData` is set, so the resulting `data` is never `undefined`.
|
||||
*
|
||||
* @see {@link useQuery} to run a query with these options.
|
||||
* @see [The Query Options API](https://tkdodo.eu/blog/the-query-options-api) for more on this pattern.
|
||||
* @param options - The {@link DefinedInitialDataOptions} to use — everything you can pass to `useQuery`, with `initialData` set.
|
||||
* @returns The same options object, typed so that `queryKey` carries the inferred data type.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { queryOptions, useQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* export const postsOptions = queryOptions({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* initialData: [],
|
||||
* })
|
||||
*
|
||||
* function Posts() {
|
||||
* // `data` is `Post[]`, never `undefined`, thanks to `initialData` — even if a refetch fails,
|
||||
* // so the list stays visible alongside the error.
|
||||
* const { data, isError, error } = useQuery(postsOptions)
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {isError ? <span>Error: {error.message}</span> : null}
|
||||
* <ul>
|
||||
* {data.map((post) => <li key={post.id}>{post.title}</li>)}
|
||||
* </ul>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function queryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> &
|
||||
QueryKeyWithDataTag<TQueryKey, TQueryFnData, TError>
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `queryOptions` that you can also pass to `useQuery`. These options can
|
||||
* be shared across hooks and imperative APIs such as `queryClient.query`. `options.queryKey` is required and
|
||||
* is the query key to generate options for.
|
||||
*
|
||||
* @see {@link useQuery} to run a query with these options.
|
||||
* @see [The Query Options API](https://tkdodo.eu/blog/the-query-options-api) for more on this pattern.
|
||||
* @param options - The {@link UnusedSkipTokenOptions} to use — everything you can pass to `useQuery`.
|
||||
* @returns The same options object, typed so that `queryKey` carries the inferred data type.
|
||||
*
|
||||
* @example
|
||||
* A parameterized factory, so the same options object can be reused per `id`:
|
||||
* ```tsx
|
||||
* import { queryOptions, useQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* export const postOptions = (id: string) =>
|
||||
* queryOptions({
|
||||
* queryKey: ['post', id],
|
||||
* queryFn: () => fetchPost(id),
|
||||
* })
|
||||
*
|
||||
* function Post({ id }: { id: string }) {
|
||||
* const { data, isPending, isError, error } = useQuery(postOptions(id))
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <h1>{data.title}</h1>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function queryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UnusedSkipTokenOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): UnusedSkipTokenOptions<TQueryFnData, TError, TData, TQueryKey> &
|
||||
QueryKeyWithDataTag<TQueryKey, TQueryFnData, TError>
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `queryOptions` that you can also pass to `useQuery`. These options can
|
||||
* be shared across hooks and imperative APIs such as `queryClient.query`. `options.queryKey` is required and
|
||||
* is the query key to generate options for.
|
||||
*
|
||||
* @see {@link useQuery} to run a query with these options.
|
||||
* @see [The Query Options API](https://tkdodo.eu/blog/the-query-options-api) for more on this pattern.
|
||||
* @param options - The {@link UndefinedInitialDataOptions} to use — everything you can pass to `useQuery`.
|
||||
* @returns The same options object, typed so that `queryKey` carries the inferred data type.
|
||||
* @remarks This is the only overload that accepts `queryFn: skipToken`, shown below.
|
||||
*
|
||||
* @example
|
||||
* A parameterized factory, so the same options object can be reused per `id`:
|
||||
* ```tsx
|
||||
* import { queryOptions, useQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* export const postOptions = (id: string) =>
|
||||
* queryOptions({
|
||||
* queryKey: ['post', id],
|
||||
* queryFn: () => fetchPost(id),
|
||||
* })
|
||||
*
|
||||
* function Post({ id }: { id: string }) {
|
||||
* const { data, isPending, isError, error } = useQuery(postOptions(id))
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <h1>{data.title}</h1>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* A factory that disables the query, type safe, until `postId` is set:
|
||||
* ```tsx
|
||||
* import { queryOptions, skipToken, useQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* export const postOptions = (postId: number | undefined) =>
|
||||
* queryOptions({
|
||||
* queryKey: ['post', postId],
|
||||
* queryFn: postId != null ? () => fetchPost(postId) : skipToken,
|
||||
* })
|
||||
*
|
||||
* function Post({ postId }: { postId: number | undefined }) {
|
||||
* const { data, isLoading, isError, error } = useQuery(postOptions(postId))
|
||||
*
|
||||
* if (postId == null) return 'Select a post'
|
||||
* if (isLoading) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <h1>{data?.title}</h1>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function queryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> &
|
||||
QueryKeyWithDataTag<TQueryKey, TQueryFnData, TError>
|
||||
|
||||
export function queryOptions(options: unknown) {
|
||||
return options
|
||||
}
|
||||
75
packages/react-query/src/suspense.ts
Normal file
75
packages/react-query/src/suspense.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
DefaultError,
|
||||
DefaultedQueryObserverOptions,
|
||||
Query,
|
||||
QueryKey,
|
||||
QueryObserver,
|
||||
QueryObserverResult,
|
||||
} from '@tanstack/query-core'
|
||||
import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary'
|
||||
|
||||
export const defaultThrowOnError = <
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
_error: TError,
|
||||
query: Query<TQueryFnData, TError, TData, TQueryKey>,
|
||||
) => query.state.data === undefined
|
||||
|
||||
export const ensureSuspenseTimers = (
|
||||
defaultedOptions: DefaultedQueryObserverOptions<any, any, any, any, any>,
|
||||
) => {
|
||||
if (defaultedOptions.suspense) {
|
||||
// Handle staleTime to ensure minimum 1000ms in Suspense mode
|
||||
// This prevents unnecessary refetching when components remount after suspending
|
||||
const MIN_SUSPENSE_TIME_MS = 1000
|
||||
|
||||
const clamp = (value: number | 'static' | undefined) =>
|
||||
value === 'static'
|
||||
? value
|
||||
: Math.max(value ?? MIN_SUSPENSE_TIME_MS, MIN_SUSPENSE_TIME_MS)
|
||||
|
||||
const originalStaleTime = defaultedOptions.staleTime
|
||||
defaultedOptions.staleTime =
|
||||
typeof originalStaleTime === 'function'
|
||||
? (...args) => clamp(originalStaleTime(...args))
|
||||
: clamp(originalStaleTime)
|
||||
|
||||
if (typeof defaultedOptions.gcTime === 'number') {
|
||||
defaultedOptions.gcTime = Math.max(
|
||||
defaultedOptions.gcTime,
|
||||
MIN_SUSPENSE_TIME_MS,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const shouldSuspend = (
|
||||
defaultedOptions:
|
||||
| DefaultedQueryObserverOptions<any, any, any, any, any>
|
||||
| undefined,
|
||||
result: QueryObserverResult<any, any>,
|
||||
) => defaultedOptions?.suspense && result.isPending
|
||||
|
||||
export const fetchOptimistic = <
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey extends QueryKey,
|
||||
>(
|
||||
defaultedOptions: DefaultedQueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
observer: QueryObserver<TQueryFnData, TError, TData, TQueryData, TQueryKey>,
|
||||
errorResetBoundary: QueryErrorResetBoundaryValue,
|
||||
) =>
|
||||
observer.fetchOptimistic(defaultedOptions).catch(() => {
|
||||
errorResetBoundary.clearReset()
|
||||
})
|
||||
505
packages/react-query/src/types.ts
Normal file
505
packages/react-query/src/types.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
import type {
|
||||
DefaultError,
|
||||
DefinedInfiniteQueryObserverResult,
|
||||
DefinedQueryObserverResult,
|
||||
DistributiveOmit,
|
||||
InfiniteData,
|
||||
InfiniteQueryExecuteOptions,
|
||||
InfiniteQueryObserverOptions,
|
||||
InfiniteQueryObserverResult,
|
||||
MutateFunction,
|
||||
MutationObserverOptions,
|
||||
MutationObserverResult,
|
||||
OmitKeyof,
|
||||
Override,
|
||||
QueryExecuteOptions,
|
||||
QueryKey,
|
||||
QueryObserverOptions,
|
||||
QueryObserverResult,
|
||||
SkipToken,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
/**
|
||||
* {@link UseBaseQueryOptions} with all type parameters set to `any`, useful when the specific types aren't
|
||||
* relevant, e.g. when accepting options for any query in a helper function.
|
||||
*/
|
||||
export type AnyUseBaseQueryOptions = UseBaseQueryOptions<
|
||||
any,
|
||||
any,
|
||||
any,
|
||||
any,
|
||||
any
|
||||
>
|
||||
/**
|
||||
* The options shared by `useQuery` and `useSuspenseQuery`. Extends {@link QueryObserverOptions} from
|
||||
* `@tanstack/query-core` with the `react-query`-specific `subscribed` option.
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs. Defaults to `TQueryFnData` when no
|
||||
* `select` is used.
|
||||
* @template TQueryData - The type of the data actually held in the query cache — the input to `select` and
|
||||
* `placeholderData`. Defaults to, and is usually the same as, `TQueryFnData`.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export interface UseBaseQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> extends QueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
> {
|
||||
/**
|
||||
* Set this to `false` to unsubscribe this observer from updates to the query cache.
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
subscribed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The options accepted by `usePrefetchQuery` — everything you can pass to `queryClient.query`, except `queryFn`
|
||||
* is required unless a default query function has been defined.
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs. Defaults to `TQueryFnData` when no
|
||||
* `select` is used.
|
||||
* @template TQueryData - The type of the data actually held in the query cache — the input to `select` and
|
||||
* `placeholderData`. Defaults to, and is usually the same as, `TQueryFnData`.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export type UsePrefetchQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = DistributiveOmit<
|
||||
QueryExecuteOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>,
|
||||
'queryFn'
|
||||
> & {
|
||||
/**
|
||||
* `skipToken` is not allowed as a value here — a prefetch always needs a query function to actually run,
|
||||
* unless a default query function has been defined.
|
||||
*/
|
||||
queryFn?: Exclude<
|
||||
QueryExecuteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>['queryFn'],
|
||||
SkipToken
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* The options accepted by `usePrefetchInfiniteQuery` — everything you can pass to `queryClient.infiniteQuery`,
|
||||
* except `queryFn` is required unless a default query function has been defined.
|
||||
*
|
||||
* @template TQueryFnData - The type of a single page, as your `queryFn` resolves it.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs. Defaults to `InfiniteData<TQueryFnData>`,
|
||||
* the shape of all fetched pages plus their page params — a prefetch never reads `data` back out, so this
|
||||
* parameter only matters if you reuse these options elsewhere with `select` applied.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
* @template TPageParam - The type of the parameter passed to `queryFn` to fetch a given page.
|
||||
*/
|
||||
export type UsePrefetchInfiniteQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
> = DistributiveOmit<
|
||||
InfiniteQueryExecuteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
'queryFn'
|
||||
> & {
|
||||
/**
|
||||
* `skipToken` is not allowed as a value here — a prefetch always needs a query function to actually run,
|
||||
* unless a default query function has been defined.
|
||||
*/
|
||||
queryFn?: Exclude<
|
||||
InfiniteQueryExecuteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>['queryFn'],
|
||||
SkipToken
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link UseQueryOptions} with all type parameters set to `any`, useful when the specific types aren't
|
||||
* relevant, e.g. when accepting options for any query in a helper function.
|
||||
*/
|
||||
export type AnyUseQueryOptions = UseQueryOptions<any, any, any, any>
|
||||
/**
|
||||
* The options accepted by `useQuery`. Same as {@link UseBaseQueryOptions}, minus `suspense` (which
|
||||
* `react-query` derives from which hook you call rather than exposing as an option).
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs. Defaults to `TQueryFnData` when no
|
||||
* `select` is used.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export interface UseQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> extends OmitKeyof<
|
||||
UseBaseQueryOptions<TQueryFnData, TError, TData, TQueryFnData, TQueryKey>,
|
||||
'suspense'
|
||||
> {}
|
||||
|
||||
/**
|
||||
* {@link UseSuspenseQueryOptions} with all type parameters set to `any`, useful when the specific types aren't
|
||||
* relevant, e.g. when accepting options for any query in a helper function.
|
||||
*/
|
||||
export type AnyUseSuspenseQueryOptions = UseSuspenseQueryOptions<
|
||||
any,
|
||||
any,
|
||||
any,
|
||||
any
|
||||
>
|
||||
/**
|
||||
* The options accepted by `useSuspenseQuery`. Same as {@link UseQueryOptions}, minus `enabled`, `throwOnError`,
|
||||
* and `placeholderData` — Suspense hooks cannot render a "disabled" or "placeholder" state, so those options
|
||||
* don't apply.
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs. Defaults to `TQueryFnData` when no
|
||||
* `select` is used.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export interface UseSuspenseQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> extends OmitKeyof<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
'queryFn' | 'enabled' | 'throwOnError' | 'placeholderData'
|
||||
> {
|
||||
/**
|
||||
* `skipToken` is not allowed here — Suspense hooks cannot render a "disabled" state, so a query function
|
||||
* must always be provided, unless a default query function has been defined.
|
||||
*/
|
||||
queryFn?: Exclude<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>['queryFn'],
|
||||
SkipToken
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link UseInfiniteQueryOptions} with all type parameters set to `any`, useful when the specific types aren't
|
||||
* relevant, e.g. when accepting options for any query in a helper function.
|
||||
*/
|
||||
export type AnyUseInfiniteQueryOptions = UseInfiniteQueryOptions<
|
||||
any,
|
||||
any,
|
||||
any,
|
||||
any,
|
||||
any
|
||||
>
|
||||
/**
|
||||
* The options accepted by `useInfiniteQuery`. Extends {@link InfiniteQueryObserverOptions} from
|
||||
* `@tanstack/query-core` with the `react-query`-specific `subscribed` option, minus `suspense` (which
|
||||
* `react-query` derives from which hook you call rather than exposing as an option).
|
||||
*
|
||||
* @template TQueryFnData - The type of a single page, as your `queryFn` resolves it.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs — defaults to `InfiniteData<TQueryFnData>`,
|
||||
* the shape of all fetched pages plus their page params.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
* @template TPageParam - The type of the parameter passed to `queryFn` to fetch a given page.
|
||||
*/
|
||||
export interface UseInfiniteQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
> extends OmitKeyof<
|
||||
InfiniteQueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
'suspense'
|
||||
> {
|
||||
/**
|
||||
* Set this to `false` to unsubscribe this observer from updates to the query cache.
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
subscribed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link UseSuspenseInfiniteQueryOptions} with all type parameters set to `any`, useful when the specific types
|
||||
* aren't relevant, e.g. when accepting options for any query in a helper function.
|
||||
*/
|
||||
export type AnyUseSuspenseInfiniteQueryOptions =
|
||||
UseSuspenseInfiniteQueryOptions<any, any, any, any, any>
|
||||
/**
|
||||
* The options accepted by `useSuspenseInfiniteQuery`. Same as {@link UseInfiniteQueryOptions}, minus `enabled`,
|
||||
* `throwOnError`, and `placeholderData` — Suspense hooks cannot render a "disabled" or "placeholder" state, so
|
||||
* those options don't apply.
|
||||
*
|
||||
* @template TQueryFnData - The type of a single page, as your `queryFn` resolves it.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs — defaults to `InfiniteData<TQueryFnData>`,
|
||||
* the shape of all fetched pages plus their page params.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
* @template TPageParam - The type of the parameter passed to `queryFn` to fetch a given page.
|
||||
*/
|
||||
export interface UseSuspenseInfiniteQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
> extends OmitKeyof<
|
||||
UseInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>,
|
||||
'queryFn' | 'enabled' | 'throwOnError' | 'placeholderData'
|
||||
> {
|
||||
/**
|
||||
* `skipToken` is not allowed here — Suspense hooks cannot render a "disabled" state, so a query function
|
||||
* must always be provided, unless a default query function has been defined.
|
||||
*/
|
||||
queryFn?: Exclude<
|
||||
UseInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>['queryFn'],
|
||||
SkipToken
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of `useQuery` when `initialData` isn't set — `data` may be `undefined` while the query is
|
||||
* `pending`. Re-exports {@link QueryObserverResult} from `@tanstack/query-core`. `useInfiniteQuery` returns
|
||||
* {@link UseInfiniteQueryResult} instead.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type UseBaseQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = QueryObserverResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The result of `useQuery`. Same as {@link UseBaseQueryResult}.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type UseQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = UseBaseQueryResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The result of `useSuspenseQuery`. Same as {@link DefinedUseQueryResult}, minus `isPlaceholderData` — always
|
||||
* `false` on that type, so this drops the dead field rather than an active state.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type UseSuspenseQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = DistributiveOmit<
|
||||
DefinedQueryObserverResult<TData, TError>,
|
||||
'isPlaceholderData'
|
||||
>
|
||||
|
||||
/**
|
||||
* The result of `useQuery` when `initialData` is set, or of `useSuspenseQuery` before the `isPlaceholderData`
|
||||
* omission — `data` is never `undefined`. Re-exports {@link DefinedQueryObserverResult} from
|
||||
* `@tanstack/query-core`.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type DefinedUseQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = DefinedQueryObserverResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The result of `useInfiniteQuery` when `initialData` isn't set — `data` may be `undefined` while the query is
|
||||
* `pending`. Re-exports {@link InfiniteQueryObserverResult} from `@tanstack/query-core`.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type UseInfiniteQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = InfiniteQueryObserverResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The result of `useInfiniteQuery` when `initialData` is set — `data` is never `undefined`. Re-exports
|
||||
* {@link DefinedInfiniteQueryObserverResult} from `@tanstack/query-core`.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type DefinedUseInfiniteQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = DefinedInfiniteQueryObserverResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The result of `useSuspenseInfiniteQuery`. Same as {@link DefinedUseInfiniteQueryResult}, minus
|
||||
* `isPlaceholderData` — Suspense hooks never render placeholder data.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type UseSuspenseInfiniteQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = OmitKeyof<
|
||||
DefinedInfiniteQueryObserverResult<TData, TError>,
|
||||
'isPlaceholderData'
|
||||
>
|
||||
|
||||
/**
|
||||
* {@link UseMutationOptions} with all type parameters set to `any`, useful when the specific types aren't
|
||||
* relevant, e.g. when accepting options for any mutation in a helper function.
|
||||
*/
|
||||
export type AnyUseMutationOptions = UseMutationOptions<any, any, any, any>
|
||||
/**
|
||||
* The options accepted by `useMutation`. Same as {@link MutationObserverOptions} from `@tanstack/query-core`,
|
||||
* minus the internal `_defaulted` flag.
|
||||
*
|
||||
* @template TData - The type your mutation function resolves to.
|
||||
* @template TError - The type of errors your mutation function may throw.
|
||||
* @template TVariables - The type of the variable passed to `mutate`/`mutateAsync`.
|
||||
* @template TOnMutateResult - The type returned by `onMutate`, passed to `onSuccess`/`onError`/`onSettled` as
|
||||
* their `onMutateResult` parameter — useful for optimistic-update rollback data.
|
||||
*/
|
||||
export interface UseMutationOptions<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
> extends OmitKeyof<
|
||||
MutationObserverOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
'_defaulted'
|
||||
> {}
|
||||
|
||||
/**
|
||||
* The type of `mutate`, as returned by `useMutation`. Forwards the variables (and an optional per-call
|
||||
* `onSuccess`/`onError`/`onSettled`) to the underlying `mutate` call. Fire-and-forget — errors are surfaced
|
||||
* through the mutation result, not thrown.
|
||||
*
|
||||
* @template TData - The type your mutation function resolves to.
|
||||
* @template TError - The type of errors your mutation function may throw.
|
||||
* @template TVariables - The type of the variable passed to `mutate`.
|
||||
* @template TOnMutateResult - The type returned by `onMutate`, passed to `onSuccess`/`onError`/`onSettled` as
|
||||
* their `onMutateResult` parameter — useful for optimistic-update rollback data.
|
||||
*/
|
||||
export type UseMutateFunction<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
> = (
|
||||
...args: Parameters<
|
||||
MutateFunction<TData, TError, TVariables, TOnMutateResult>
|
||||
>
|
||||
) => void
|
||||
|
||||
/**
|
||||
* The type of `mutateAsync`, as returned by `useMutation`. Similar to {@link UseMutateFunction}, but returns a
|
||||
* promise which can be awaited.
|
||||
*
|
||||
* @template TData - The type your mutation function resolves to.
|
||||
* @template TError - The type of errors your mutation function may throw.
|
||||
* @template TVariables - The type of the variable passed to `mutateAsync`.
|
||||
* @template TOnMutateResult - The type returned by `onMutate`, passed to `onSuccess`/`onError`/`onSettled` as
|
||||
* their `onMutateResult` parameter — useful for optimistic-update rollback data.
|
||||
*/
|
||||
export type UseMutateAsyncFunction<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
> = MutateFunction<TData, TError, TVariables, TOnMutateResult>
|
||||
|
||||
/**
|
||||
* The result of `useMutation`. Same as {@link MutationObserverResult} from `@tanstack/query-core`, with
|
||||
* `mutate` narrowed to the fire-and-forget {@link UseMutateFunction} signature, plus the added `mutateAsync`.
|
||||
*
|
||||
* @template TData - The type your mutation function resolves to.
|
||||
* @template TError - The type of errors your mutation function may throw.
|
||||
* @template TVariables - The type of the variable passed to `mutate`/`mutateAsync`.
|
||||
* @template TOnMutateResult - The type returned by `onMutate`, passed to `onSuccess`/`onError`/`onSettled` as
|
||||
* their `onMutateResult` parameter — useful for optimistic-update rollback data.
|
||||
*/
|
||||
export type UseBaseMutationResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = unknown,
|
||||
TOnMutateResult = unknown,
|
||||
> = Override<
|
||||
MutationObserverResult<TData, TError, TVariables, TOnMutateResult>,
|
||||
{ mutate: UseMutateFunction<TData, TError, TVariables, TOnMutateResult> }
|
||||
> & {
|
||||
/**
|
||||
* Similar to `mutate`, but returns a promise which can be awaited.
|
||||
*/
|
||||
mutateAsync: UseMutateAsyncFunction<
|
||||
TData,
|
||||
TError,
|
||||
TVariables,
|
||||
TOnMutateResult
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of `useMutation`. Same as {@link UseBaseMutationResult}.
|
||||
*
|
||||
* @template TData - The type your mutation function resolves to.
|
||||
* @template TError - The type of errors your mutation function may throw.
|
||||
* @template TVariables - The type of the variable passed to `mutate`/`mutateAsync`.
|
||||
* @template TOnMutateResult - The type returned by `onMutate`, passed to `onSuccess`/`onError`/`onSettled` as
|
||||
* their `onMutateResult` parameter — useful for optimistic-update rollback data.
|
||||
*/
|
||||
export type UseMutationResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = unknown,
|
||||
TOnMutateResult = unknown,
|
||||
> = UseBaseMutationResult<TData, TError, TVariables, TOnMutateResult>
|
||||
143
packages/react-query/src/useBaseQuery.ts
Normal file
143
packages/react-query/src/useBaseQuery.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
|
||||
import { noop, notifyManager } from '@tanstack/query-core'
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary'
|
||||
import {
|
||||
ensurePreventErrorBoundaryRetry,
|
||||
getHasError,
|
||||
useClearResetErrorBoundary,
|
||||
} from './errorBoundaryUtils'
|
||||
import { useIsRestoring } from './IsRestoringProvider'
|
||||
import {
|
||||
ensureSuspenseTimers,
|
||||
fetchOptimistic,
|
||||
shouldSuspend,
|
||||
} from './suspense'
|
||||
import type {
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
QueryObserver,
|
||||
QueryObserverResult,
|
||||
} from '@tanstack/query-core'
|
||||
import type { UseBaseQueryOptions } from './types'
|
||||
|
||||
export function useBaseQuery<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey extends QueryKey,
|
||||
>(
|
||||
options: UseBaseQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
Observer: typeof QueryObserver,
|
||||
queryClient?: QueryClient,
|
||||
): QueryObserverResult<TData, TError> {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new Error(
|
||||
'Bad argument type. Starting with v5, only the "Object" form is allowed when calling query related functions. Please use the error stack to find the culprit call. More info here: https://tanstack.com/query/latest/docs/react/guides/migrating-to-v5#supports-a-single-signature-one-object',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const isRestoring = useIsRestoring()
|
||||
const errorResetBoundary = useQueryErrorResetBoundary()
|
||||
const client = useQueryClient(queryClient)
|
||||
const defaultedOptions = client.defaultQueryOptions(options)
|
||||
|
||||
const query = client
|
||||
.getQueryCache()
|
||||
.get<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>(defaultedOptions.queryHash)
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (!defaultedOptions.queryFn) {
|
||||
console.error(
|
||||
`[${defaultedOptions.queryHash}]: No queryFn was passed as an option, and no default queryFn was found. The queryFn parameter is only optional when using a default queryFn. More info here: https://tanstack.com/query/latest/docs/framework/react/guides/default-query-function`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const subscribed = options.subscribed !== false
|
||||
|
||||
// Make sure results are optimistically set in fetching state before subscribing or updating options
|
||||
defaultedOptions._optimisticResults = isRestoring
|
||||
? 'isRestoring'
|
||||
: subscribed
|
||||
? 'optimistic'
|
||||
: undefined
|
||||
|
||||
ensureSuspenseTimers(defaultedOptions)
|
||||
ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary, query)
|
||||
useClearResetErrorBoundary(errorResetBoundary)
|
||||
|
||||
const [observer] = React.useState(
|
||||
() =>
|
||||
new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
|
||||
client,
|
||||
defaultedOptions,
|
||||
),
|
||||
)
|
||||
|
||||
// note: this must be called before useSyncExternalStore
|
||||
const result = observer.getOptimisticResult(defaultedOptions)
|
||||
|
||||
const shouldSubscribe = !isRestoring && subscribed
|
||||
React.useSyncExternalStore(
|
||||
React.useCallback(
|
||||
(onStoreChange) => {
|
||||
const unsubscribe = shouldSubscribe
|
||||
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
|
||||
: noop
|
||||
|
||||
// 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, shouldSubscribe],
|
||||
),
|
||||
() => observer.getCurrentResult(),
|
||||
() => observer.getCurrentResult(),
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
observer.setOptions(defaultedOptions)
|
||||
}, [defaultedOptions, observer])
|
||||
|
||||
// Handle suspense
|
||||
if (shouldSuspend(defaultedOptions, result)) {
|
||||
throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
|
||||
}
|
||||
|
||||
// Handle error boundary
|
||||
if (
|
||||
getHasError({
|
||||
result,
|
||||
errorResetBoundary,
|
||||
throwOnError: defaultedOptions.throwOnError,
|
||||
query,
|
||||
suspense: defaultedOptions.suspense,
|
||||
})
|
||||
) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
// Handle result property usage tracking
|
||||
return !defaultedOptions.notifyOnChangeProps
|
||||
? observer.trackResult(result)
|
||||
: result
|
||||
}
|
||||
373
packages/react-query/src/useInfiniteQuery.ts
Normal file
373
packages/react-query/src/useInfiniteQuery.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
'use client'
|
||||
import { InfiniteQueryObserver } from '@tanstack/query-core'
|
||||
import { useBaseQuery } from './useBaseQuery'
|
||||
import type {
|
||||
DefaultError,
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
QueryObserver,
|
||||
} from '@tanstack/query-core'
|
||||
import type {
|
||||
DefinedUseInfiniteQueryResult,
|
||||
UseInfiniteQueryOptions,
|
||||
UseInfiniteQueryResult,
|
||||
} from './types'
|
||||
import type {
|
||||
DefinedInitialDataInfiniteOptions,
|
||||
UndefinedInitialDataInfiniteOptions,
|
||||
} from './infiniteQueryOptions'
|
||||
|
||||
/**
|
||||
* The options for `useInfiniteQuery` are identical to `useQuery`, with the addition of
|
||||
* `initialPageParam`, `getNextPageParam`, `getPreviousPageParam`, and `maxPages`.
|
||||
*
|
||||
* This overload is selected when `initialData` is set.
|
||||
*
|
||||
* @remarks Keep in mind that imperative fetch calls, such as `fetchNextPage`, may interfere with the default
|
||||
* refetch behavior, resulting in outdated data. Make sure to call these functions only in response to user
|
||||
* actions, or add conditions like `hasNextPage && !isFetching`.
|
||||
* @see {@link infiniteQueryOptions} to share these options between `useInfiniteQuery` and imperative APIs like `queryClient.infiniteQuery`.
|
||||
* @param options - The {@link DefinedInitialDataInfiniteOptions} to use — everything you can pass to `useInfiniteQuery`, with `initialData` set.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The same properties as `useQuery`, with the addition of `fetchNextPage`, `fetchPreviousPage`,
|
||||
* `hasNextPage`, `hasPreviousPage`, `isFetchingNextPage`, and `isFetchingPreviousPage`. `data.pages` and
|
||||
* `data.pageParams` are also added, as long as a `select` doesn't change `TData` away from its default
|
||||
* `InfiniteData<TQueryFnData>` shape.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* function Projects() {
|
||||
* // `data` is never `undefined`, thanks to `initialData` — even if a refetch fails, so the
|
||||
* // list stays visible alongside the error.
|
||||
* const { data, isError, error } = useInfiniteQuery({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* initialData: { pages: [], pageParams: [] },
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {isError ? <span>Error: {error.message}</span> : null}
|
||||
* <ul>
|
||||
* {data.pages.map((page) => page.projects.map((p) => <li key={p.id}>{p.name}</li>))}
|
||||
* </ul>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useInfiniteQuery<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: DefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
queryClient?: QueryClient,
|
||||
): DefinedUseInfiniteQueryResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The options for `useInfiniteQuery` are identical to `useQuery`, with the addition of
|
||||
* `initialPageParam`, `getNextPageParam`, `getPreviousPageParam`, and `maxPages`.
|
||||
*
|
||||
* @remarks Keep in mind that imperative fetch calls, such as `fetchNextPage`, may interfere with the default
|
||||
* refetch behavior, resulting in outdated data. Make sure to call these functions only in response to user
|
||||
* actions, or add conditions like `hasNextPage && !isFetching`.
|
||||
* @see {@link infiniteQueryOptions} to share these options between `useInfiniteQuery` and imperative APIs like `queryClient.infiniteQuery`.
|
||||
* @param options - The {@link UndefinedInitialDataInfiniteOptions} to use — everything you can pass to `useInfiniteQuery`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The same properties as `useQuery`, with the addition of `fetchNextPage`, `fetchPreviousPage`,
|
||||
* `hasNextPage`, `hasPreviousPage`, `isFetchingNextPage`, and `isFetchingPreviousPage`. `data.pages` and
|
||||
* `data.pageParams` are also added, as long as a `select` doesn't change `TData` away from its default
|
||||
* `InfiniteData<TQueryFnData>` shape.
|
||||
*
|
||||
* @example
|
||||
* Fetching the next page from a "Load More" button click:
|
||||
* ```tsx
|
||||
* import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* function Projects() {
|
||||
* const { data, isPending, isError, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
|
||||
* useInfiniteQuery({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <ul>
|
||||
* {data.pages.map((page) =>
|
||||
* page.projects.map((project) => <li key={project.id}>{project.name}</li>),
|
||||
* )}
|
||||
* </ul>
|
||||
* <button
|
||||
* onClick={() => fetchNextPage()}
|
||||
* disabled={!hasNextPage || isFetching}
|
||||
* >
|
||||
* {isFetchingNextPage
|
||||
* ? 'Loading more...'
|
||||
* : hasNextPage
|
||||
* ? 'Load More'
|
||||
* : 'Nothing more to load'}
|
||||
* </button>
|
||||
* </>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Fetching the next page automatically as the user scrolls, using an `IntersectionObserver` on a
|
||||
* sentinel element after the list:
|
||||
* ```tsx
|
||||
* import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
* import { useEffect, useRef } from 'react'
|
||||
*
|
||||
* function Projects() {
|
||||
* const {
|
||||
* data,
|
||||
* isPending,
|
||||
* isError,
|
||||
* error,
|
||||
* fetchNextPage,
|
||||
* hasNextPage,
|
||||
* isFetching,
|
||||
* isFetchingNextPage,
|
||||
* } = useInfiniteQuery({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
*
|
||||
* useEffect(() => {
|
||||
* const sentinel = sentinelRef.current
|
||||
* if (sentinel == null || !hasNextPage || isFetching) return
|
||||
*
|
||||
* const observer = new IntersectionObserver(([entry]) => {
|
||||
* if (entry?.isIntersecting) fetchNextPage()
|
||||
* })
|
||||
* observer.observe(sentinel)
|
||||
*
|
||||
* return () => observer.disconnect()
|
||||
* }, [hasNextPage, isFetching, fetchNextPage])
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <ul>
|
||||
* {data.pages.map((page) =>
|
||||
* page.projects.map((project) => <li key={project.id}>{project.name}</li>),
|
||||
* )}
|
||||
* </ul>
|
||||
* <div ref={sentinelRef}>{isFetchingNextPage ? 'Loading more...' : null}</div>
|
||||
* </>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useInfiniteQuery<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: UndefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
queryClient?: QueryClient,
|
||||
): UseInfiniteQueryResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The options for `useInfiniteQuery` are identical to `useQuery`, with the addition of
|
||||
* `initialPageParam`, `getNextPageParam`, `getPreviousPageParam`, and `maxPages`.
|
||||
*
|
||||
* @remarks Keep in mind that imperative fetch calls, such as `fetchNextPage`, may interfere with the default
|
||||
* refetch behavior, resulting in outdated data. Make sure to call these functions only in response to user
|
||||
* actions, or add conditions like `hasNextPage && !isFetching`.
|
||||
* @see {@link infiniteQueryOptions} to share these options between `useInfiniteQuery` and imperative APIs like `queryClient.infiniteQuery`.
|
||||
* @param options - The {@link UseInfiniteQueryOptions} to use — everything you can pass to `useInfiniteQuery`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The same properties as `useQuery`, with the addition of `fetchNextPage`, `fetchPreviousPage`,
|
||||
* `hasNextPage`, `hasPreviousPage`, `isFetchingNextPage`, and `isFetchingPreviousPage`. `data.pages` and
|
||||
* `data.pageParams` are also added, as long as a `select` doesn't change `TData` away from its default
|
||||
* `InfiniteData<TQueryFnData>` shape.
|
||||
*
|
||||
* @example
|
||||
* Fetching the next page from a "Load More" button click:
|
||||
* ```tsx
|
||||
* import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* function Projects() {
|
||||
* const { data, isPending, isError, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
|
||||
* useInfiniteQuery({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <ul>
|
||||
* {data.pages.map((page) =>
|
||||
* page.projects.map((project) => <li key={project.id}>{project.name}</li>),
|
||||
* )}
|
||||
* </ul>
|
||||
* <button
|
||||
* onClick={() => fetchNextPage()}
|
||||
* disabled={!hasNextPage || isFetching}
|
||||
* >
|
||||
* {isFetchingNextPage
|
||||
* ? 'Loading more...'
|
||||
* : hasNextPage
|
||||
* ? 'Load More'
|
||||
* : 'Nothing more to load'}
|
||||
* </button>
|
||||
* </>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Fetching the next page automatically as the user scrolls, using an `IntersectionObserver` on a
|
||||
* sentinel element after the list:
|
||||
* ```tsx
|
||||
* import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
* import { useEffect, useRef } from 'react'
|
||||
*
|
||||
* function Projects() {
|
||||
* const {
|
||||
* data,
|
||||
* isPending,
|
||||
* isError,
|
||||
* error,
|
||||
* fetchNextPage,
|
||||
* hasNextPage,
|
||||
* isFetching,
|
||||
* isFetchingNextPage,
|
||||
* } = useInfiniteQuery({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
*
|
||||
* useEffect(() => {
|
||||
* const sentinel = sentinelRef.current
|
||||
* if (sentinel == null || !hasNextPage || isFetching) return
|
||||
*
|
||||
* const observer = new IntersectionObserver(([entry]) => {
|
||||
* if (entry?.isIntersecting) fetchNextPage()
|
||||
* })
|
||||
* observer.observe(sentinel)
|
||||
*
|
||||
* return () => observer.disconnect()
|
||||
* }, [hasNextPage, isFetching, fetchNextPage])
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <ul>
|
||||
* {data.pages.map((page) =>
|
||||
* page.projects.map((project) => <li key={project.id}>{project.name}</li>),
|
||||
* )}
|
||||
* </ul>
|
||||
* <div ref={sentinelRef}>{isFetchingNextPage ? 'Loading more...' : null}</div>
|
||||
* </>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* A query that's disabled, type safe, until `postId` is set — pass `skipToken` as `queryFn`
|
||||
* instead of setting `enabled: false`:
|
||||
* ```tsx
|
||||
* import { skipToken, useInfiniteQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* function Comments({ postId }: { postId: string | undefined }) {
|
||||
* // Use `isLoading`, not `isPending`, so the loading state doesn't show while the query is disabled.
|
||||
* const { data, isLoading, isError, error } = useInfiniteQuery({
|
||||
* queryKey: ['post', postId, 'comments'],
|
||||
* queryFn:
|
||||
* postId != null
|
||||
* ? ({ pageParam }) => fetchComments(postId, pageParam)
|
||||
* : skipToken,
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* if (postId == null) return 'Select a post'
|
||||
* if (isLoading) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {data?.pages.map((page) => page.comments.map((c) => <li key={c.id}>{c.text}</li>))}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useInfiniteQuery<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: UseInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
queryClient?: QueryClient,
|
||||
): UseInfiniteQueryResult<TData, TError>
|
||||
|
||||
export function useInfiniteQuery(
|
||||
options: UseInfiniteQueryOptions,
|
||||
queryClient?: QueryClient,
|
||||
) {
|
||||
return useBaseQuery(
|
||||
options,
|
||||
InfiniteQueryObserver as typeof QueryObserver,
|
||||
queryClient,
|
||||
)
|
||||
}
|
||||
60
packages/react-query/src/useIsFetching.ts
Normal file
60
packages/react-query/src/useIsFetching.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
import { notifyManager } from '@tanstack/query-core'
|
||||
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
import type { QueryClient, QueryFilters } from '@tanstack/query-core'
|
||||
|
||||
/**
|
||||
* The `useIsFetching` hook returns the `number` of the queries that your application is loading or fetching in
|
||||
* the background (useful for app-wide loading indicators).
|
||||
*
|
||||
* @param filters - The {@link QueryFilters} to narrow down the matched queries.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns Will be the `number` of the queries that your application is currently loading or fetching in the
|
||||
* background.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useIsFetching } from '@tanstack/react-query'
|
||||
*
|
||||
* function PostsFetchingIndicator() {
|
||||
* // How many queries matching the posts prefix are fetching?
|
||||
* const isFetchingPosts = useIsFetching({ queryKey: ['posts'] })
|
||||
*
|
||||
* return isFetchingPosts ? <span>Refreshing posts...</span> : null
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* A global loading indicator for any query fetching in the background, not just the ones on screen:
|
||||
* ```tsx
|
||||
* import { useIsFetching } from '@tanstack/react-query'
|
||||
*
|
||||
* function GlobalLoadingIndicator() {
|
||||
* const isFetching = useIsFetching()
|
||||
*
|
||||
* return isFetching ? (
|
||||
* <div>Queries are fetching in the background...</div>
|
||||
* ) : null
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useIsFetching(
|
||||
filters?: QueryFilters,
|
||||
queryClient?: QueryClient,
|
||||
): number {
|
||||
const client = useQueryClient(queryClient)
|
||||
const queryCache = client.getQueryCache()
|
||||
|
||||
return React.useSyncExternalStore(
|
||||
React.useCallback(
|
||||
(onStoreChange) =>
|
||||
queryCache.subscribe(notifyManager.batchCalls(onStoreChange)),
|
||||
[queryCache],
|
||||
),
|
||||
() => client.isFetching(filters),
|
||||
() => client.isFetching(filters),
|
||||
)
|
||||
}
|
||||
245
packages/react-query/src/useMutation.ts
Normal file
245
packages/react-query/src/useMutation.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
MutationObserver,
|
||||
noop,
|
||||
notifyManager,
|
||||
shouldThrowError,
|
||||
} from '@tanstack/query-core'
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
import type {
|
||||
UseMutateFunction,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
} from './types'
|
||||
import type { DefaultError, QueryClient } from '@tanstack/query-core'
|
||||
|
||||
// HOOK
|
||||
|
||||
/**
|
||||
* Unlike queries, mutations are typically used to create/update/delete data or perform server side-effects.
|
||||
* `useMutation` is the hook for that.
|
||||
*
|
||||
* @see {@link mutationOptions} to share these options across multiple `useMutation` call sites, or to look
|
||||
* the mutation up elsewhere via its `mutationKey` (e.g. with `useMutationState`).
|
||||
* @param options - The {@link UseMutationOptions} to use — everything you can pass to `useMutation`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns `mutate`/`mutateAsync` also accept per-call `onSuccess`/`onError`/`onSettled` callbacks as a second
|
||||
* argument, useful for triggering call-site side effects (e.g. navigation) without coupling them to the shared
|
||||
* mutation definition. Hook-level callbacks (passed to `options`) fire for every mutation; per-call callbacks
|
||||
* fire only for the latest call you've made, and only while the component is still mounted — unmounting before
|
||||
* the mutation settles removes the subscription and prevents them from firing.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
*
|
||||
* function AddTodo() {
|
||||
* const queryClient = useQueryClient()
|
||||
*
|
||||
* const addMutation = useMutation({
|
||||
* mutationFn: addTodo,
|
||||
* onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <button
|
||||
* onClick={() =>
|
||||
* addMutation.mutate('Item', {
|
||||
* onError: (error) => console.error('Failed to add item:', error),
|
||||
* })
|
||||
* }
|
||||
* >
|
||||
* Add
|
||||
* </button>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Rendering the mutation's own state, rather than just firing it off:
|
||||
* ```tsx
|
||||
* import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
*
|
||||
* function AddTodo() {
|
||||
* const queryClient = useQueryClient()
|
||||
*
|
||||
* const addMutation = useMutation({
|
||||
* mutationFn: addTodo,
|
||||
* onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {addMutation.isPending ? (
|
||||
* 'Adding todo...'
|
||||
* ) : (
|
||||
* <>
|
||||
* {addMutation.isError ? (
|
||||
* <div>An error occurred: {addMutation.error.message}</div>
|
||||
* ) : null}
|
||||
* <button onClick={() => addMutation.mutate('Item')}>Add</button>
|
||||
* </>
|
||||
* )}
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Optimistic update via `onMutate`, rolling back on `onError`:
|
||||
* ```tsx
|
||||
* import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
*
|
||||
* function AddTodo() {
|
||||
* const queryClient = useQueryClient()
|
||||
*
|
||||
* const addMutation = useMutation({
|
||||
* mutationFn: addTodo,
|
||||
* onMutate: async (newTodo) => {
|
||||
* await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||
* const previousTodos = queryClient.getQueryData<Array<string>>(['todos'])
|
||||
*
|
||||
* queryClient.setQueryData<Array<string>>(['todos'], (old) => [
|
||||
* ...(old ?? []),
|
||||
* newTodo,
|
||||
* ])
|
||||
*
|
||||
* // Passed to `onError` as `onMutateResult` if the mutation fails.
|
||||
* return { previousTodos }
|
||||
* },
|
||||
* onError: (_err, _newTodo, onMutateResult) => {
|
||||
* queryClient.setQueryData(['todos'], onMutateResult?.previousTodos)
|
||||
* },
|
||||
* onSettled: () => {
|
||||
* queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||
* },
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <button onClick={() => addMutation.mutate('Item')}>Add</button>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Callbacks passed per call to `mutate` only fire for the last call — `mutateAsync` gives you a
|
||||
* promise per call instead, so you can wait for all of them when they succeed:
|
||||
* ```tsx
|
||||
* import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
*
|
||||
* function AddTodos() {
|
||||
* const queryClient = useQueryClient()
|
||||
*
|
||||
* const addMutation = useMutation({
|
||||
* mutationFn: addTodo,
|
||||
* onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
|
||||
* })
|
||||
*
|
||||
* async function handleAddAll(todos: Array<string>) {
|
||||
* try {
|
||||
* await Promise.all(todos.map((todo) => addMutation.mutateAsync(todo)))
|
||||
* } catch (error) {
|
||||
* console.error('Failed to add todos:', error)
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* return (
|
||||
* <button onClick={() => handleAddAll(['Todo 1', 'Todo 2', 'Todo 3'])}>
|
||||
* Add all
|
||||
* </button>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* If some of the mutations above can fail independently of the others, and you want to know which ones
|
||||
* did — rather than losing that information the moment the first one rejects — swap `Promise.all` for
|
||||
* `Promise.allSettled`:
|
||||
* ```tsx
|
||||
* import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
*
|
||||
* function AddTodos() {
|
||||
* const queryClient = useQueryClient()
|
||||
*
|
||||
* const addMutation = useMutation({
|
||||
* mutationFn: addTodo,
|
||||
* onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
|
||||
* })
|
||||
*
|
||||
* async function handleAddAll(todos: Array<string>) {
|
||||
* const addResults = await Promise.allSettled(
|
||||
* todos.map((todo) => addMutation.mutateAsync(todo)),
|
||||
* )
|
||||
*
|
||||
* addResults.forEach((addResult, index) => {
|
||||
* if (addResult.status === 'rejected') {
|
||||
* console.error(`Failed to add "${todos[index]}":`, addResult.reason)
|
||||
* }
|
||||
* })
|
||||
* }
|
||||
*
|
||||
* return (
|
||||
* <button onClick={() => handleAddAll(['Todo 1', 'Todo 2', 'Todo 3'])}>
|
||||
* Add all
|
||||
* </button>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useMutation<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
>(
|
||||
options: UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
queryClient?: QueryClient,
|
||||
): UseMutationResult<TData, TError, TVariables, TOnMutateResult> {
|
||||
const client = useQueryClient(queryClient)
|
||||
|
||||
const [observer] = React.useState(
|
||||
() =>
|
||||
new MutationObserver<TData, TError, TVariables, TOnMutateResult>(
|
||||
client,
|
||||
options,
|
||||
),
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
observer.setOptions(options)
|
||||
}, [observer, options])
|
||||
|
||||
const result = React.useSyncExternalStore(
|
||||
React.useCallback(
|
||||
(onStoreChange) =>
|
||||
observer.subscribe(notifyManager.batchCalls(onStoreChange)),
|
||||
[observer],
|
||||
),
|
||||
() => observer.getCurrentResult(),
|
||||
() => observer.getCurrentResult(),
|
||||
)
|
||||
|
||||
const mutate = React.useCallback<
|
||||
UseMutateFunction<TData, TError, TVariables, TOnMutateResult>
|
||||
>(
|
||||
(
|
||||
...args: Parameters<
|
||||
UseMutateFunction<TData, TError, TVariables, TOnMutateResult>
|
||||
>
|
||||
) => {
|
||||
observer.mutate(args[0] as TVariables, args[1]).catch(noop)
|
||||
},
|
||||
[observer],
|
||||
)
|
||||
|
||||
if (
|
||||
result.error &&
|
||||
shouldThrowError(observer.options.throwOnError, [result.error])
|
||||
) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
return { ...result, mutate, mutateAsync: result.mutate }
|
||||
}
|
||||
194
packages/react-query/src/useMutationState.ts
Normal file
194
packages/react-query/src/useMutationState.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
|
||||
import { notifyManager, replaceEqualDeep } from '@tanstack/query-core'
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
import type {
|
||||
Mutation,
|
||||
MutationCache,
|
||||
MutationFilters,
|
||||
MutationState,
|
||||
QueryClient,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
/**
|
||||
* The `useIsMutating` hook returns the `number` of mutations that your application currently has `pending`
|
||||
* (useful for app-wide loading indicators).
|
||||
*
|
||||
* @param filters - The {@link MutationFilters} to narrow down the matched mutations.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns Will be the `number` of the mutations that your application currently has `pending`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useIsMutating } from '@tanstack/react-query'
|
||||
*
|
||||
* function PostsMutatingIndicator() {
|
||||
* // How many mutations matching the posts prefix are in progress?
|
||||
* const isMutatingPosts = useIsMutating({ mutationKey: ['posts'] })
|
||||
*
|
||||
* return isMutatingPosts ? <span>Saving posts...</span> : null
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useIsMutating(
|
||||
filters?: MutationFilters,
|
||||
queryClient?: QueryClient,
|
||||
): number {
|
||||
const client = useQueryClient(queryClient)
|
||||
return useMutationState(
|
||||
{ filters: { ...filters, status: 'pending' } },
|
||||
client,
|
||||
).length
|
||||
}
|
||||
|
||||
type MutationTypeFromResult<TResult> = [TResult] extends [
|
||||
MutationState<
|
||||
infer TData,
|
||||
infer TError,
|
||||
infer TVariables,
|
||||
infer TOnMutateResult
|
||||
>,
|
||||
]
|
||||
? Mutation<TData, TError, TVariables, TOnMutateResult>
|
||||
: Mutation
|
||||
|
||||
type MutationStateOptions<
|
||||
TResult = MutationState,
|
||||
TMutation extends Mutation<any, any, any, any> =
|
||||
MutationTypeFromResult<TResult>,
|
||||
> = {
|
||||
filters?: MutationFilters
|
||||
select?: (mutation: TMutation) => TResult
|
||||
}
|
||||
|
||||
function getResult<
|
||||
TResult = MutationState,
|
||||
TMutation extends Mutation<any, any, any, any> =
|
||||
MutationTypeFromResult<TResult>,
|
||||
>(
|
||||
mutationCache: MutationCache,
|
||||
options: MutationStateOptions<TResult, TMutation>,
|
||||
): Array<TResult> {
|
||||
return mutationCache
|
||||
.findAll(options.filters)
|
||||
.map(
|
||||
(mutation): TResult =>
|
||||
(options.select
|
||||
? options.select(mutation as TMutation)
|
||||
: mutation.state) as TResult,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `useMutationState` is a hook that gives you access to all mutations in the `MutationCache`. You can pass
|
||||
* `filters` ({@link MutationFilters}) to narrow down your mutations, and `select` to transform the mutation
|
||||
* state.
|
||||
*
|
||||
* @param options - The `filters` to narrow down matched mutations, and an optional `select` to transform the
|
||||
* mutation state.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns Will be an Array of whatever `select` returns for each matching mutation.
|
||||
*
|
||||
* @example
|
||||
* Get all variables of all running mutations:
|
||||
* ```tsx
|
||||
* import { useMutationState } from '@tanstack/react-query'
|
||||
*
|
||||
* function PendingPosts() {
|
||||
* const pendingVariables = useMutationState({
|
||||
* filters: { status: 'pending' },
|
||||
* select: (mutation) => mutation.state.variables,
|
||||
* })
|
||||
*
|
||||
* return <>{pendingVariables.length} posts saving...</>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Get all data for specific mutations via the `mutationKey`:
|
||||
* ```tsx
|
||||
* import { useMutation, useMutationState } from '@tanstack/react-query'
|
||||
*
|
||||
* const mutationKey = ['posts']
|
||||
*
|
||||
* function Posts() {
|
||||
* // Some mutation that we want to get the state for
|
||||
* const mutation = useMutation({
|
||||
* mutationKey,
|
||||
* mutationFn: createPosts,
|
||||
* })
|
||||
*
|
||||
* const savedPosts = useMutationState({
|
||||
* // this mutation key needs to match the mutation key of the given mutation (see above)
|
||||
* filters: { mutationKey, status: 'success' },
|
||||
* select: (mutation) => mutation.state.data,
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <button onClick={() => mutation.mutate(['New Post'])}>
|
||||
* Create post ({savedPosts.length} saved so far)
|
||||
* </button>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Access the latest successful mutation data via the `mutationKey`. Each invocation of `mutate` adds a new
|
||||
* entry to the mutation cache for `gcTime` milliseconds — with the `status: 'success'` filter below, check the
|
||||
* last item that `useMutationState` returns to get the latest successful invocation:
|
||||
* ```tsx
|
||||
* import { useMutationState } from '@tanstack/react-query'
|
||||
*
|
||||
* function LatestPost() {
|
||||
* const savedPosts = useMutationState({
|
||||
* filters: { mutationKey: ['posts'], status: 'success' },
|
||||
* select: (mutation) => mutation.state.data,
|
||||
* })
|
||||
*
|
||||
* const latestSavedPost = savedPosts[savedPosts.length - 1]
|
||||
*
|
||||
* return <>{latestSavedPost ? 'Saved' : 'Nothing saved yet'}</>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useMutationState<
|
||||
TResult = MutationState,
|
||||
TMutation extends Mutation<any, any, any, any> =
|
||||
MutationTypeFromResult<TResult>,
|
||||
>(
|
||||
options: MutationStateOptions<TResult, TMutation> = {},
|
||||
queryClient?: QueryClient,
|
||||
): Array<TResult> {
|
||||
const mutationCache = useQueryClient(queryClient).getMutationCache()
|
||||
const optionsRef = React.useRef(options)
|
||||
const result = React.useRef<Array<TResult>>(null)
|
||||
if (result.current === null) {
|
||||
result.current = getResult(mutationCache, options)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
optionsRef.current = options
|
||||
})
|
||||
|
||||
return React.useSyncExternalStore(
|
||||
React.useCallback(
|
||||
(onStoreChange) =>
|
||||
mutationCache.subscribe(() => {
|
||||
const nextResult = replaceEqualDeep(
|
||||
result.current,
|
||||
getResult(mutationCache, optionsRef.current),
|
||||
)
|
||||
if (result.current !== nextResult) {
|
||||
result.current = nextResult
|
||||
notifyManager.schedule(onStoreChange)
|
||||
}
|
||||
}),
|
||||
[mutationCache],
|
||||
),
|
||||
() => result.current,
|
||||
() => result.current,
|
||||
)!
|
||||
}
|
||||
77
packages/react-query/src/usePrefetchInfiniteQuery.tsx
Normal file
77
packages/react-query/src/usePrefetchInfiniteQuery.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { noop } from '@tanstack/query-core'
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
|
||||
import type {
|
||||
DefaultError,
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
} from '@tanstack/query-core'
|
||||
import type { UsePrefetchInfiniteQueryOptions } from './types'
|
||||
|
||||
/**
|
||||
* `usePrefetchInfiniteQuery` does not return anything, it should be used just to fire a prefetch during render,
|
||||
* before a suspense boundary that wraps a component that uses `useSuspenseInfiniteQuery`. You can pass
|
||||
* everything to `usePrefetchInfiniteQuery` that you can pass to `queryClient.infiniteQuery`, though
|
||||
* `queryKey`, `initialPageParam`, and `getNextPageParam` are always required, and `queryFn` is required unless
|
||||
* a default query function has been defined.
|
||||
*
|
||||
* `getNextPageParam` receives both the last page of the infinite list of data and the full array of all pages,
|
||||
* as well as pageParam information, and should return a single variable that will be passed to your query
|
||||
* function as `context.pageParam`. Return `undefined` or `null` to indicate there is no next page available.
|
||||
*
|
||||
* The prefetch is skipped if the query already has any cached state — including a `pending`/`error` state left
|
||||
* over from a previous attempt — so calling this on every render is cheap and won't refetch data that's
|
||||
* already there or already in flight.
|
||||
*
|
||||
* @param options - The {@link UsePrefetchInfiniteQueryOptions} to use — everything you can pass to `queryClient.infiniteQuery`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns `void` — nothing is returned.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Suspense } from 'react'
|
||||
* import { infiniteQueryOptions, usePrefetchInfiniteQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* const projectsOptions = infiniteQueryOptions({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* function App() {
|
||||
* // Fire the prefetch during render, before the suspense boundary below.
|
||||
* usePrefetchInfiniteQuery(projectsOptions)
|
||||
*
|
||||
* return (
|
||||
* <Suspense fallback={<h1>Loading projects...</h1>}>
|
||||
* <Projects />
|
||||
* </Suspense>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function usePrefetchInfiniteQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: UsePrefetchInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
queryClient?: QueryClient,
|
||||
): void {
|
||||
const client = useQueryClient(queryClient)
|
||||
|
||||
if (!client.getQueryState(options.queryKey)) {
|
||||
void client.infiniteQuery(options).catch(noop)
|
||||
}
|
||||
}
|
||||
63
packages/react-query/src/usePrefetchQuery.tsx
Normal file
63
packages/react-query/src/usePrefetchQuery.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { noop } from '@tanstack/query-core'
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
|
||||
import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core'
|
||||
import type { UsePrefetchQueryOptions } from './types'
|
||||
|
||||
/**
|
||||
* `usePrefetchQuery` does not return anything, it should be used just to fire a prefetch during render, before
|
||||
* a suspense boundary that wraps a component that uses `useSuspenseQuery`. You can pass everything to
|
||||
* `usePrefetchQuery` that you can pass to `queryClient.query`, though `queryKey` is always required, and
|
||||
* `queryFn` is required unless a default query function has been defined.
|
||||
*
|
||||
* The prefetch is skipped if the query already has any cached state — including a `pending`/`error` state left
|
||||
* over from a previous attempt — so calling this on every render is cheap and won't refetch data that's
|
||||
* already there or already in flight.
|
||||
*
|
||||
* @param options - The {@link UsePrefetchQueryOptions} to use — everything you can pass to `queryClient.query`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns `void` — nothing is returned.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Suspense } from 'react'
|
||||
* import { usePrefetchQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* function App() {
|
||||
* // Fire the prefetch during render, before the suspense boundary below.
|
||||
* usePrefetchQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <Suspense fallback={<h1>Loading posts...</h1>}>
|
||||
* <Posts />
|
||||
* </Suspense>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function usePrefetchQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UsePrefetchQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
queryClient?: QueryClient,
|
||||
): void {
|
||||
const client = useQueryClient(queryClient)
|
||||
|
||||
if (!client.getQueryState(options.queryKey)) {
|
||||
void client.query(options).catch(noop)
|
||||
}
|
||||
}
|
||||
494
packages/react-query/src/useQueries.ts
Normal file
494
packages/react-query/src/useQueries.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
|
||||
import {
|
||||
QueriesObserver,
|
||||
QueryObserver,
|
||||
noop,
|
||||
notifyManager,
|
||||
} from '@tanstack/query-core'
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
import { useIsRestoring } from './IsRestoringProvider'
|
||||
import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary'
|
||||
import {
|
||||
ensurePreventErrorBoundaryRetry,
|
||||
getHasError,
|
||||
useClearResetErrorBoundary,
|
||||
} from './errorBoundaryUtils'
|
||||
import {
|
||||
ensureSuspenseTimers,
|
||||
fetchOptimistic,
|
||||
shouldSuspend,
|
||||
} from './suspense'
|
||||
import type {
|
||||
DefinedUseQueryResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from './types'
|
||||
import type {
|
||||
DefaultError,
|
||||
OmitKeyof,
|
||||
QueriesObserverOptions,
|
||||
QueriesPlaceholderDataFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
QueryObserverOptions,
|
||||
ThrowOnError,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
// This defines the `UseQueryOptions` that are accepted in `QueriesOptions` & `GetOptions`.
|
||||
// `placeholderData` function always gets undefined passed
|
||||
type UseQueryOptionsForUseQueries<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = OmitKeyof<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
'placeholderData' | 'subscribed'
|
||||
> & {
|
||||
placeholderData?: TQueryFnData | QueriesPlaceholderDataFunction<TQueryFnData>
|
||||
}
|
||||
|
||||
// Avoid TS depth-limit error in case of large array literal
|
||||
type MAXIMUM_DEPTH = 20
|
||||
|
||||
// Widen the type of the symbol to enable type inference even if skipToken is not immutable.
|
||||
type SkipTokenForUseQueries = symbol
|
||||
|
||||
type GetUseQueryOptionsForUseQueries<T> =
|
||||
// Part 1: responsible for applying explicit type parameter to function arguments, if object { queryFnData: TQueryFnData, error: TError, data: TData }
|
||||
T extends {
|
||||
queryFnData: infer TQueryFnData
|
||||
error?: infer TError
|
||||
data: infer TData
|
||||
}
|
||||
? UseQueryOptionsForUseQueries<TQueryFnData, TError, TData>
|
||||
: T extends { queryFnData: infer TQueryFnData; error?: infer TError }
|
||||
? UseQueryOptionsForUseQueries<TQueryFnData, TError>
|
||||
: T extends { data: infer TData; error?: infer TError }
|
||||
? UseQueryOptionsForUseQueries<unknown, TError, TData>
|
||||
: // Part 2: responsible for applying explicit type parameter to function arguments, if tuple [TQueryFnData, TError, TData]
|
||||
T extends [infer TQueryFnData, infer TError, infer TData]
|
||||
? UseQueryOptionsForUseQueries<TQueryFnData, TError, TData>
|
||||
: T extends [infer TQueryFnData, infer TError]
|
||||
? UseQueryOptionsForUseQueries<TQueryFnData, TError>
|
||||
: T extends [infer TQueryFnData]
|
||||
? UseQueryOptionsForUseQueries<TQueryFnData>
|
||||
: // Part 3: responsible for inferring and enforcing type if no explicit parameter was provided
|
||||
T extends {
|
||||
queryFn?:
|
||||
| QueryFunction<infer TQueryFnData, infer TQueryKey>
|
||||
| SkipTokenForUseQueries
|
||||
select?: (data: any) => infer TData
|
||||
throwOnError?: ThrowOnError<any, infer TError, any, any>
|
||||
}
|
||||
? UseQueryOptionsForUseQueries<
|
||||
TQueryFnData,
|
||||
unknown extends TError ? DefaultError : TError,
|
||||
unknown extends TData ? TQueryFnData : TData,
|
||||
TQueryKey
|
||||
>
|
||||
: // Fallback
|
||||
UseQueryOptionsForUseQueries
|
||||
|
||||
// A defined initialData setting should return a DefinedUseQueryResult rather than UseQueryResult
|
||||
type GetDefinedOrUndefinedQueryResult<T, TData, TError = unknown> = T extends {
|
||||
initialData?: infer TInitialData
|
||||
}
|
||||
? unknown extends TInitialData
|
||||
? UseQueryResult<TData, TError>
|
||||
: TInitialData extends TData
|
||||
? DefinedUseQueryResult<TData, TError>
|
||||
: TInitialData extends () => infer TInitialDataResult
|
||||
? unknown extends TInitialDataResult
|
||||
? UseQueryResult<TData, TError>
|
||||
: TInitialDataResult extends TData
|
||||
? DefinedUseQueryResult<TData, TError>
|
||||
: UseQueryResult<TData, TError>
|
||||
: UseQueryResult<TData, TError>
|
||||
: UseQueryResult<TData, TError>
|
||||
|
||||
type GetUseQueryResult<T> =
|
||||
// Part 1: responsible for mapping explicit type parameter to function result, if object
|
||||
T extends { queryFnData: any; error?: infer TError; data: infer TData }
|
||||
? GetDefinedOrUndefinedQueryResult<T, TData, TError>
|
||||
: T extends { queryFnData: infer TQueryFnData; error?: infer TError }
|
||||
? GetDefinedOrUndefinedQueryResult<T, TQueryFnData, TError>
|
||||
: T extends { data: infer TData; error?: infer TError }
|
||||
? GetDefinedOrUndefinedQueryResult<T, TData, TError>
|
||||
: // Part 2: responsible for mapping explicit type parameter to function result, if tuple
|
||||
T extends [any, infer TError, infer TData]
|
||||
? GetDefinedOrUndefinedQueryResult<T, TData, TError>
|
||||
: T extends [infer TQueryFnData, infer TError]
|
||||
? GetDefinedOrUndefinedQueryResult<T, TQueryFnData, TError>
|
||||
: T extends [infer TQueryFnData]
|
||||
? GetDefinedOrUndefinedQueryResult<T, TQueryFnData>
|
||||
: // Part 3: responsible for mapping inferred type to results, if no explicit parameter was provided
|
||||
T extends {
|
||||
queryFn?:
|
||||
| QueryFunction<infer TQueryFnData, any>
|
||||
| SkipTokenForUseQueries
|
||||
select?: (data: any) => infer TData
|
||||
throwOnError?: ThrowOnError<any, infer TError, any, any>
|
||||
}
|
||||
? GetDefinedOrUndefinedQueryResult<
|
||||
T,
|
||||
unknown extends TData ? TQueryFnData : TData,
|
||||
unknown extends TError ? DefaultError : TError
|
||||
>
|
||||
: // Fallback
|
||||
UseQueryResult
|
||||
|
||||
/**
|
||||
* The `queries` array accepted by `useQueries`. Recursively unwraps each tuple element so every entry's
|
||||
* `queryFn`/`select`/`throwOnError` are inferred individually, up to 20 elements. An opaque array (e.g.
|
||||
* `unknown[]`) is returned as-is; a non-tuple array of a known element type, or a tuple past 20 elements, falls
|
||||
* back to a single homogeneous options type.
|
||||
*
|
||||
* @template T - The type of the `queries` array as written at the call site.
|
||||
* @template TResults - The internal accumulator that this type builds during recursion. It is not meant
|
||||
* to be set explicitly.
|
||||
* @template TDepth - The internal recursion-depth counter, checked against the 20-element limit. It is not
|
||||
* meant to be set explicitly.
|
||||
*/
|
||||
export type QueriesOptions<
|
||||
T extends Array<any>,
|
||||
TResults extends Array<any> = [],
|
||||
TDepth extends ReadonlyArray<number> = [],
|
||||
> = TDepth['length'] extends MAXIMUM_DEPTH
|
||||
? Array<UseQueryOptionsForUseQueries>
|
||||
: T extends []
|
||||
? []
|
||||
: T extends [infer Head]
|
||||
? [...TResults, GetUseQueryOptionsForUseQueries<Head>]
|
||||
: T extends [infer Head, ...infer Tails]
|
||||
? QueriesOptions<
|
||||
[...Tails],
|
||||
[...TResults, GetUseQueryOptionsForUseQueries<Head>],
|
||||
[...TDepth, 1]
|
||||
>
|
||||
: ReadonlyArray<unknown> extends T
|
||||
? T
|
||||
: // If T is *some* array but we couldn't assign unknown[] to it, then it must hold some known/homogeneous type!
|
||||
// use this to infer the param types in the case of Array.map() argument
|
||||
T extends Array<
|
||||
UseQueryOptionsForUseQueries<
|
||||
infer TQueryFnData,
|
||||
infer TError,
|
||||
infer TData,
|
||||
infer TQueryKey
|
||||
>
|
||||
>
|
||||
? Array<
|
||||
UseQueryOptionsForUseQueries<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey
|
||||
>
|
||||
>
|
||||
: // Fallback
|
||||
Array<UseQueryOptionsForUseQueries>
|
||||
|
||||
/**
|
||||
* The result type returned by `useQueries`, when no `combine` is provided. Mirrors {@link QueriesOptions}: each
|
||||
* tuple element's result type is inferred individually, up to 20 elements. A non-tuple array is mapped
|
||||
* per-element instead, still inferring each entry individually; only past 20 elements does this fall back to a
|
||||
* single homogeneous {@link UseQueryResult} type.
|
||||
*
|
||||
* @template T - The type of the `queries` array, as inferred by {@link QueriesOptions}.
|
||||
* @template TResults - The internal accumulator that this type builds during recursion. It is not meant
|
||||
* to be set explicitly.
|
||||
* @template TDepth - The internal recursion-depth counter, checked against the 20-element limit. It is not
|
||||
* meant to be set explicitly.
|
||||
*/
|
||||
export type QueriesResults<
|
||||
T extends Array<any>,
|
||||
TResults extends Array<any> = [],
|
||||
TDepth extends ReadonlyArray<number> = [],
|
||||
> = TDepth['length'] extends MAXIMUM_DEPTH
|
||||
? Array<UseQueryResult>
|
||||
: T extends []
|
||||
? []
|
||||
: T extends [infer Head]
|
||||
? [...TResults, GetUseQueryResult<Head>]
|
||||
: T extends [infer Head, ...infer Tails]
|
||||
? QueriesResults<
|
||||
[...Tails],
|
||||
[...TResults, GetUseQueryResult<Head>],
|
||||
[...TDepth, 1]
|
||||
>
|
||||
: { [K in keyof T]: GetUseQueryResult<T[K]> }
|
||||
|
||||
/**
|
||||
* The `useQueries` hook can be used to fetch a variable number of queries.
|
||||
*
|
||||
* The `queries` key accepts an array with query option objects mostly identical to `useQuery` — the top-level
|
||||
* `subscribed` option isn't accepted per query (see `placeholderData` below for another difference). A custom
|
||||
* `QueryClient` is supplied once, as `useQueries`' own top-level second argument, rather than per query.
|
||||
*
|
||||
* Having the same query key more than once in the array of query objects may cause some data to be shared
|
||||
* between queries. To avoid this, consider de-duplicating the queries and map the results back to the desired
|
||||
* structure.
|
||||
*
|
||||
* The `combine` option can be used to combine the results of the queries into a single value. The result will
|
||||
* be structurally shared to be as referentially stable as possible.
|
||||
*
|
||||
* @remarks The `combine` function only re-runs if it changed referentially, or if any of the query results
|
||||
* changed. An inlined `combine` function, as shown in the example below, therefore runs on every render — wrap
|
||||
* it in `useCallback`, or extract it to a stable function reference if it doesn't have any dependencies, to
|
||||
* avoid that.
|
||||
*
|
||||
* Unlike `useQuery`, `useQueries` cannot infer the `data` argument of an _inline_ `select` from its sibling
|
||||
* `queryFn`. Because `useQueries` infers the type of the whole `queries` array at once, the `select` parameter
|
||||
* of a query object written inline cannot be contextually typed from that same object's `queryFn`, so it falls
|
||||
* back to `unknown` — a [known TypeScript limitation](https://github.com/TanStack/query/issues/6556). Annotate
|
||||
* the `select` parameter explicitly, or define the query with {@link queryOptions}, which resolves its types in
|
||||
* a single object _before_ it reaches `useQueries`, to work around this — see the example below. The same
|
||||
* limitation applies to {@link useSuspenseQueries}.
|
||||
*
|
||||
* `placeholderData` is supported here too, but unlike `useQuery`, it doesn't receive information from
|
||||
* previously rendered queries, because the number of queries can differ between renders.
|
||||
* @param queryClient - Use this to provide a custom `QueryClient`. Otherwise, the one from the nearest context
|
||||
* will be used.
|
||||
* @returns The combined result. Without `combine`, this is an array with all the query results, in the same
|
||||
* order as the input. When `combine` is provided, this is the value returned by `combine` instead.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useQueries } from '@tanstack/react-query'
|
||||
*
|
||||
* function Posts({ ids }: { ids: Array<number> }) {
|
||||
* const postQueries = useQueries({
|
||||
* queries: ids.map((id) => ({
|
||||
* queryKey: ['post', id],
|
||||
* queryFn: () => fetchPost(id),
|
||||
* staleTime: Infinity,
|
||||
* })),
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {postQueries.map((query, index) => {
|
||||
* if (query.isPending) return <li key={ids[index]}>Loading...</li>
|
||||
* if (query.isError) return <li key={ids[index]}>Error: {query.error.message}</li>
|
||||
* return <li key={ids[index]}>{query.data.title}</li>
|
||||
* })}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Combining results into a single value:
|
||||
* ```tsx
|
||||
* import { useQueries } from '@tanstack/react-query'
|
||||
*
|
||||
* function Posts({ ids }: { ids: Array<number> }) {
|
||||
* const { data, isPending, isError } = useQueries({
|
||||
* queries: ids.map((id) => ({
|
||||
* queryKey: ['post', id],
|
||||
* queryFn: () => fetchPost(id),
|
||||
* })),
|
||||
* combine: (postQueries) => {
|
||||
* return {
|
||||
* data: postQueries.map((query) => query.data),
|
||||
* isPending: postQueries.some((query) => query.isPending),
|
||||
* isError: postQueries.some((query) => query.isError),
|
||||
* }
|
||||
* },
|
||||
* })
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return 'Error loading posts'
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {data.map((post) => (
|
||||
* <li key={post?.id}>{post?.title}</li>
|
||||
* ))}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Typing `select` via {@link queryOptions}. Note that spreading a `queryOptions` result and overriding
|
||||
* `select` inline still falls back to `unknown` — wrap the spread in `queryOptions` again so the override is
|
||||
* resolved before it reaches `useQueries`:
|
||||
* ```tsx
|
||||
* import { queryOptions, useQueries } from '@tanstack/react-query'
|
||||
*
|
||||
* const postOptions = (id: number) =>
|
||||
* queryOptions({
|
||||
* queryKey: ['post', id],
|
||||
* queryFn: () => fetchPost(id),
|
||||
* })
|
||||
*
|
||||
* function PostTitle({ id }: { id: number }) {
|
||||
* const [{ data: broken }] = useQueries({
|
||||
* queries: [
|
||||
* {
|
||||
* ...postOptions(id),
|
||||
* // ❌ `data` is `unknown` here
|
||||
* select: (data) => data.title,
|
||||
* },
|
||||
* ],
|
||||
* })
|
||||
*
|
||||
* const [{ data: fixed }] = useQueries({
|
||||
* queries: [
|
||||
* queryOptions({
|
||||
* ...postOptions(id),
|
||||
* // ✅ `data` is `Post`
|
||||
* select: (data) => data.title,
|
||||
* }),
|
||||
* ],
|
||||
* })
|
||||
*
|
||||
* return <h1>{fixed}</h1>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useQueries<
|
||||
T extends Array<any>,
|
||||
TCombinedResult = QueriesResults<T>,
|
||||
>(
|
||||
{
|
||||
queries,
|
||||
...options
|
||||
}: {
|
||||
/**
|
||||
* An array with query option objects, mostly identical to `useQuery` — except that `queryClient` and
|
||||
* `subscribed` aren't accepted per-query (`subscribed` is a top-level option here instead), and
|
||||
* `placeholderData` accepts a {@link QueriesPlaceholderDataFunction}, which is called with `previousData`
|
||||
* and `previousQuery` always `undefined`, rather than `useQuery`'s placeholder function.
|
||||
*/
|
||||
queries:
|
||||
| readonly [...QueriesOptions<T>]
|
||||
| readonly [...{ [K in keyof T]: GetUseQueryOptionsForUseQueries<T[K]> }]
|
||||
/**
|
||||
* Use this to combine the results of the queries into a single value. The result will be structurally
|
||||
* shared to be as referentially stable as possible.
|
||||
*/
|
||||
combine?: (result: QueriesResults<T>) => TCombinedResult
|
||||
/**
|
||||
* Set this to `false` to unsubscribe this observer from updates to the query cache.
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
subscribed?: boolean
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): TCombinedResult {
|
||||
const client = useQueryClient(queryClient)
|
||||
const isRestoring = useIsRestoring()
|
||||
const errorResetBoundary = useQueryErrorResetBoundary()
|
||||
const subscribed = options.subscribed !== false
|
||||
|
||||
const defaultedQueries = React.useMemo(
|
||||
() =>
|
||||
queries.map((opts) => {
|
||||
const defaultedOptions = client.defaultQueryOptions(
|
||||
opts as QueryObserverOptions,
|
||||
)
|
||||
|
||||
// Make sure the results are already in fetching state before subscribing or updating options
|
||||
defaultedOptions._optimisticResults = isRestoring
|
||||
? 'isRestoring'
|
||||
: subscribed
|
||||
? 'optimistic'
|
||||
: undefined
|
||||
|
||||
return defaultedOptions
|
||||
}),
|
||||
[queries, client, isRestoring, subscribed],
|
||||
)
|
||||
|
||||
defaultedQueries.forEach((queryOptions) => {
|
||||
ensureSuspenseTimers(queryOptions)
|
||||
const query = client.getQueryCache().get(queryOptions.queryHash)
|
||||
ensurePreventErrorBoundaryRetry(queryOptions, errorResetBoundary, query)
|
||||
})
|
||||
|
||||
useClearResetErrorBoundary(errorResetBoundary)
|
||||
|
||||
const [observer] = React.useState(
|
||||
() =>
|
||||
new QueriesObserver<TCombinedResult>(
|
||||
client,
|
||||
defaultedQueries,
|
||||
options as QueriesObserverOptions<TCombinedResult>,
|
||||
),
|
||||
)
|
||||
|
||||
// note: this must be called before useSyncExternalStore
|
||||
const [optimisticResult, getCombinedResult, trackResult] =
|
||||
observer.getOptimisticResult(
|
||||
defaultedQueries,
|
||||
(options as QueriesObserverOptions<TCombinedResult>).combine,
|
||||
)
|
||||
|
||||
const shouldSubscribe = !isRestoring && subscribed
|
||||
React.useSyncExternalStore(
|
||||
React.useCallback(
|
||||
(onStoreChange) =>
|
||||
shouldSubscribe
|
||||
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
|
||||
: noop,
|
||||
[observer, shouldSubscribe],
|
||||
),
|
||||
() => observer.getCurrentResult(),
|
||||
() => observer.getCurrentResult(),
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
observer.setQueries(
|
||||
defaultedQueries,
|
||||
options as QueriesObserverOptions<TCombinedResult>,
|
||||
)
|
||||
}, [defaultedQueries, options, observer])
|
||||
|
||||
const shouldAtLeastOneSuspend = optimisticResult.some((result, index) =>
|
||||
shouldSuspend(defaultedQueries[index], result),
|
||||
)
|
||||
|
||||
const suspensePromises = shouldAtLeastOneSuspend
|
||||
? optimisticResult.flatMap((result, index) => {
|
||||
const opts = defaultedQueries[index]
|
||||
|
||||
if (opts && shouldSuspend(opts, result)) {
|
||||
const queryObserver = new QueryObserver(client, opts)
|
||||
return fetchOptimistic(opts, queryObserver, errorResetBoundary)
|
||||
}
|
||||
return []
|
||||
})
|
||||
: []
|
||||
|
||||
if (suspensePromises.length > 0) {
|
||||
throw Promise.all(suspensePromises)
|
||||
}
|
||||
const firstSingleResultWhichShouldThrow = optimisticResult.find(
|
||||
(result, index) => {
|
||||
const query = defaultedQueries[index]
|
||||
return (
|
||||
query &&
|
||||
getHasError({
|
||||
result,
|
||||
errorResetBoundary,
|
||||
throwOnError: query.throwOnError,
|
||||
query: client.getQueryCache().get(query.queryHash),
|
||||
suspense: query.suspense,
|
||||
})
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
if (firstSingleResultWhichShouldThrow) {
|
||||
throw firstSingleResultWhichShouldThrow.error
|
||||
}
|
||||
|
||||
return getCombinedResult(trackResult())
|
||||
}
|
||||
293
packages/react-query/src/useQuery.ts
Normal file
293
packages/react-query/src/useQuery.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
'use client'
|
||||
import { QueryObserver } from '@tanstack/query-core'
|
||||
import { useBaseQuery } from './useBaseQuery'
|
||||
import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core'
|
||||
import type {
|
||||
DefinedUseQueryResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from './types'
|
||||
import type {
|
||||
DefinedInitialDataOptions,
|
||||
UndefinedInitialDataOptions,
|
||||
} from './queryOptions'
|
||||
|
||||
/**
|
||||
* This overload is selected when `initialData` is set, so the resulting `data` is never `undefined`.
|
||||
*
|
||||
* @see {@link queryOptions} to share these options between `useQuery` and imperative APIs like `queryClient.query`.
|
||||
* @param options - The {@link DefinedInitialDataOptions} to use — everything you can pass to `useQuery`, with `initialData` set.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The current query result, typed so that `status` is `success` — or `error` if a fetch attempt
|
||||
* fails while keeping the existing data (`status` never resolves to `pending` in this overload's type,
|
||||
* since `initialData` guarantees data upfront). `isSuccess`/`isError` are derived booleans for convenience.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* function Posts() {
|
||||
* // `data` is `Post[]`, never `undefined`, thanks to `initialData` — even if a refetch fails,
|
||||
* // so the list stays visible alongside the error.
|
||||
* const { data, isError, error } = useQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* initialData: [],
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {isError ? <span>Error: {error.message}</span> : null}
|
||||
* <ul>
|
||||
* {data.map((post) => <li key={post.id}>{post.title}</li>)}
|
||||
* </ul>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): DefinedUseQueryResult<TData, TError>
|
||||
|
||||
/**
|
||||
* @see {@link queryOptions} to share these options between `useQuery` and imperative APIs like `queryClient.query`.
|
||||
* @param options - The {@link UndefinedInitialDataOptions} to use — everything you can pass to `useQuery`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The current query result. `status` is `pending` if there is no cached data to display, `error` if
|
||||
* the last fetch attempt failed, or `success` if the query has data to display. `isPending`/`isSuccess`/`isError`
|
||||
* are derived booleans for convenience.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* function Posts() {
|
||||
* const { status, data, error, isFetching } = useQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* })
|
||||
*
|
||||
* if (status === 'pending') return 'Loading...'
|
||||
* if (status === 'error') return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <ul>
|
||||
* {data.map((post) => (
|
||||
* <li key={post.id}>{post.title}</li>
|
||||
* ))}
|
||||
* </ul>
|
||||
* <div>{isFetching ? 'Background Updating...' : ' '}</div>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* The same query, checking `isPending`/`isError` instead of `status` — pick whichever reads better to you:
|
||||
* ```tsx
|
||||
* import { useQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* function Posts() {
|
||||
* const { isPending, isError, data, error } = useQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* })
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {data.map((post) => <li key={post.id}>{post.title}</li>)}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError>
|
||||
|
||||
/**
|
||||
* @see {@link queryOptions} to share these options between `useQuery` and imperative APIs like `queryClient.query`.
|
||||
* @param options - The {@link UseQueryOptions} to use — everything you can pass to `useQuery`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The current query result. `status` is `pending` if there is no cached data to display, `error` if
|
||||
* the last fetch attempt failed, or `success` if the query has data to display. `isPending`/`isSuccess`/`isError`
|
||||
* are derived booleans for convenience.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* function Posts() {
|
||||
* const { status, data, error, isFetching } = useQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* })
|
||||
*
|
||||
* if (status === 'pending') return 'Loading...'
|
||||
* if (status === 'error') return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <ul>
|
||||
* {data.map((post) => (
|
||||
* <li key={post.id}>{post.title}</li>
|
||||
* ))}
|
||||
* </ul>
|
||||
* <div>{isFetching ? 'Background Updating...' : ' '}</div>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* `select` derives whatever `data` a component needs from the cached value, without changing what's
|
||||
* actually stored in the cache — the cache still holds the full `Post[]`, but `data` here is a `number`:
|
||||
* ```tsx
|
||||
* import { useQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* function PostCount() {
|
||||
* const { data, isPending, isError, error } = useQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* select: (posts) => posts.length,
|
||||
* })
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <span>{data} posts</span>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* A dependent query, only enabled once `postId` is set — use `isLoading`, not `isPending`, so the
|
||||
* loading state doesn't show while the query is disabled:
|
||||
* ```tsx
|
||||
* import { useQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* function Post({ postId }: { postId: number | undefined }) {
|
||||
* const { data, isLoading, isError, error } = useQuery({
|
||||
* queryKey: ['post', postId],
|
||||
* queryFn: () => fetchPost(postId!),
|
||||
* enabled: postId != null,
|
||||
* })
|
||||
*
|
||||
* if (postId == null) return 'Select a post'
|
||||
* if (isLoading) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <h1>{data?.title}</h1>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* The same dependent query, type safe: `skipToken` disables the query without needing the
|
||||
* non-null assertion above, since `queryFn` is only ever called when `postId` is defined.
|
||||
* `refetch` doesn't work while `queryFn` is `skipToken` — use `enabled: false` instead if you
|
||||
* need to trigger the query manually:
|
||||
* ```tsx
|
||||
* import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* function Post({ postId }: { postId: number | undefined }) {
|
||||
* const { data, isLoading, isError, error } = useQuery({
|
||||
* queryKey: ['post', postId],
|
||||
* queryFn: postId != null ? () => fetchPost(postId) : skipToken,
|
||||
* })
|
||||
*
|
||||
* if (postId == null) return 'Select a post'
|
||||
* if (isLoading) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <h1>{data?.title}</h1>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Seeding a detail query from an already-cached list, to skip the loading state:
|
||||
* ```tsx
|
||||
* import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
*
|
||||
* function Post({ postId }: { postId: number }) {
|
||||
* const queryClient = useQueryClient()
|
||||
*
|
||||
* const { data, isError, error } = useQuery({
|
||||
* queryKey: ['post', postId],
|
||||
* queryFn: () => fetchPost(postId),
|
||||
* initialData: () =>
|
||||
* queryClient
|
||||
* .getQueryData<Array<Post>>(['posts'])
|
||||
* ?.find((post) => post.id === postId),
|
||||
* })
|
||||
*
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <h1>{data?.title}</h1>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Paginated data, keeping the previous page's data visible while the next page loads:
|
||||
* ```tsx
|
||||
* import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
* import { useState } from 'react'
|
||||
*
|
||||
* function Posts() {
|
||||
* const [page, setPage] = useState(0)
|
||||
*
|
||||
* const { data, isPlaceholderData, isError, error } = useQuery({
|
||||
* queryKey: ['posts', page],
|
||||
* queryFn: () => fetchPosts(page),
|
||||
* placeholderData: keepPreviousData,
|
||||
* })
|
||||
*
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <ul>
|
||||
* {data?.map((post) => <li key={post.id}>{post.title}</li>)}
|
||||
* </ul>
|
||||
* <button
|
||||
* disabled={isPlaceholderData}
|
||||
* onClick={() => setPage((old) => old + 1)}
|
||||
* >
|
||||
* Next Page
|
||||
* </button>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError>
|
||||
|
||||
export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
|
||||
return useBaseQuery(options, QueryObserver, queryClient)
|
||||
}
|
||||
137
packages/react-query/src/useSuspenseInfiniteQuery.ts
Normal file
137
packages/react-query/src/useSuspenseInfiniteQuery.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
import { InfiniteQueryObserver, skipToken } from '@tanstack/query-core'
|
||||
import { useBaseQuery } from './useBaseQuery'
|
||||
import { defaultThrowOnError } from './suspense'
|
||||
import type {
|
||||
DefaultError,
|
||||
InfiniteData,
|
||||
InfiniteQueryObserverSuccessResult,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
QueryObserver,
|
||||
} from '@tanstack/query-core'
|
||||
import type {
|
||||
UseSuspenseInfiniteQueryOptions,
|
||||
UseSuspenseInfiniteQueryResult,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* The options for `useSuspenseInfiniteQuery` are the same as for `useInfiniteQuery`, except for `throwOnError`,
|
||||
* `enabled`, and `placeholderData`.
|
||||
*
|
||||
* Caveat: cancellation does not work.
|
||||
*
|
||||
* @remarks Multiple suspenseful query calls in the same component suspend serially, causing a request
|
||||
* waterfall — each one blocks rendering until it resolves, so the next doesn't even start fetching until
|
||||
* then. There's no way to parallelize multiple infinite queries under Suspense. Also keep in mind that
|
||||
* imperative fetch calls, such as `fetchNextPage`, may interfere with the default refetch behavior,
|
||||
* resulting in outdated data. Make sure to call these functions only in response to user actions, or add
|
||||
* conditions like `hasNextPage && !isFetching`.
|
||||
* @see {@link useInfiniteQuery} for the non-Suspense version of this hook.
|
||||
* @param options - The {@link UseSuspenseInfiniteQueryOptions} to use — the same options as `useInfiniteQuery`, minus the ones listed above.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The same object as `useInfiniteQuery`, except that `data` is guaranteed to be defined,
|
||||
* `isPlaceholderData` is missing, and `status` is either `success` or `error` (with the derived flags set
|
||||
* accordingly).
|
||||
*
|
||||
* @example
|
||||
* The query error is thrown if a fetch fails and no cached data exists yet, so an error boundary is
|
||||
* required around `<Suspense>`. A failed background refetch instead continues to render the cached data.
|
||||
* Use {@link QueryErrorResetBoundary} to let the user retry after such an error:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'react'
|
||||
* import { ErrorBoundary } from 'react-error-boundary'
|
||||
* import {
|
||||
* QueryErrorResetBoundary,
|
||||
* useSuspenseInfiniteQuery,
|
||||
* } from '@tanstack/react-query'
|
||||
*
|
||||
* function Projects() {
|
||||
* // `data` is guaranteed to be defined here — no `isPending` check needed.
|
||||
* const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
|
||||
* useSuspenseInfiniteQuery({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <ul>
|
||||
* {data.pages.map((page) =>
|
||||
* page.projects.map((project) => <li key={project.id}>{project.name}</li>),
|
||||
* )}
|
||||
* </ul>
|
||||
* <button
|
||||
* onClick={() => fetchNextPage()}
|
||||
* disabled={!hasNextPage || isFetching}
|
||||
* >
|
||||
* {isFetchingNextPage
|
||||
* ? 'Loading more...'
|
||||
* : hasNextPage
|
||||
* ? 'Load More'
|
||||
* : 'Nothing more to load'}
|
||||
* </button>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading projects...</h1>}>
|
||||
* <Projects />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSuspenseInfiniteQuery<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: UseSuspenseInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
queryClient?: QueryClient,
|
||||
): UseSuspenseInfiniteQueryResult<TData, TError> {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if ((options.queryFn as any) === skipToken) {
|
||||
console.error('skipToken is not allowed for useSuspenseInfiniteQuery')
|
||||
}
|
||||
}
|
||||
|
||||
return useBaseQuery(
|
||||
{
|
||||
...options,
|
||||
enabled: true,
|
||||
suspense: true,
|
||||
throwOnError: defaultThrowOnError,
|
||||
placeholderData: undefined,
|
||||
},
|
||||
InfiniteQueryObserver as typeof QueryObserver,
|
||||
queryClient,
|
||||
) as InfiniteQueryObserverSuccessResult<TData, TError>
|
||||
}
|
||||
529
packages/react-query/src/useSuspenseQueries.ts
Normal file
529
packages/react-query/src/useSuspenseQueries.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
'use client'
|
||||
import { skipToken } from '@tanstack/query-core'
|
||||
import { useQueries } from './useQueries'
|
||||
import { defaultThrowOnError } from './suspense'
|
||||
import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types'
|
||||
import type {
|
||||
DefaultError,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
ThrowOnError,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
// Avoid TS depth-limit error in case of large array literal
|
||||
type MAXIMUM_DEPTH = 20
|
||||
|
||||
// Widen the type of the symbol to enable type inference even if skipToken is not immutable.
|
||||
type SkipTokenForUseQueries = symbol
|
||||
|
||||
type GetUseSuspenseQueryOptions<T> =
|
||||
// Part 1: responsible for applying explicit type parameter to function arguments, if object { queryFnData: TQueryFnData, error: TError, data: TData }
|
||||
T extends {
|
||||
queryFnData: infer TQueryFnData
|
||||
error?: infer TError
|
||||
data: infer TData
|
||||
}
|
||||
? UseSuspenseQueryOptions<TQueryFnData, TError, TData>
|
||||
: T extends { queryFnData: infer TQueryFnData; error?: infer TError }
|
||||
? UseSuspenseQueryOptions<TQueryFnData, TError>
|
||||
: T extends { data: infer TData; error?: infer TError }
|
||||
? UseSuspenseQueryOptions<unknown, TError, TData>
|
||||
: // Part 2: responsible for applying explicit type parameter to function arguments, if tuple [TQueryFnData, TError, TData]
|
||||
T extends [infer TQueryFnData, infer TError, infer TData]
|
||||
? UseSuspenseQueryOptions<TQueryFnData, TError, TData>
|
||||
: T extends [infer TQueryFnData, infer TError]
|
||||
? UseSuspenseQueryOptions<TQueryFnData, TError>
|
||||
: T extends [infer TQueryFnData]
|
||||
? UseSuspenseQueryOptions<TQueryFnData>
|
||||
: // Part 3: responsible for inferring and enforcing type if no explicit parameter was provided
|
||||
T extends {
|
||||
queryFn?:
|
||||
| QueryFunction<infer TQueryFnData, infer TQueryKey>
|
||||
| SkipTokenForUseQueries
|
||||
select?: (data: any) => infer TData
|
||||
throwOnError?: ThrowOnError<any, infer TError, any, any>
|
||||
}
|
||||
? UseSuspenseQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey
|
||||
>
|
||||
: T extends {
|
||||
queryFn?:
|
||||
| QueryFunction<infer TQueryFnData, infer TQueryKey>
|
||||
| SkipTokenForUseQueries
|
||||
throwOnError?: ThrowOnError<any, infer TError, any, any>
|
||||
}
|
||||
? UseSuspenseQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TQueryFnData,
|
||||
TQueryKey
|
||||
>
|
||||
: // Fallback
|
||||
UseSuspenseQueryOptions
|
||||
|
||||
type GetUseSuspenseQueryResult<T> =
|
||||
// Part 1: responsible for mapping explicit type parameter to function result, if object
|
||||
T extends { queryFnData: any; error?: infer TError; data: infer TData }
|
||||
? UseSuspenseQueryResult<TData, TError>
|
||||
: T extends { queryFnData: infer TQueryFnData; error?: infer TError }
|
||||
? UseSuspenseQueryResult<TQueryFnData, TError>
|
||||
: T extends { data: infer TData; error?: infer TError }
|
||||
? UseSuspenseQueryResult<TData, TError>
|
||||
: // Part 2: responsible for mapping explicit type parameter to function result, if tuple
|
||||
T extends [any, infer TError, infer TData]
|
||||
? UseSuspenseQueryResult<TData, TError>
|
||||
: T extends [infer TQueryFnData, infer TError]
|
||||
? UseSuspenseQueryResult<TQueryFnData, TError>
|
||||
: T extends [infer TQueryFnData]
|
||||
? UseSuspenseQueryResult<TQueryFnData>
|
||||
: // Part 3: responsible for mapping inferred type to results, if no explicit parameter was provided
|
||||
T extends {
|
||||
queryFn?:
|
||||
| QueryFunction<infer TQueryFnData, any>
|
||||
| SkipTokenForUseQueries
|
||||
select?: (data: any) => infer TData
|
||||
throwOnError?: ThrowOnError<any, infer TError, any, any>
|
||||
}
|
||||
? UseSuspenseQueryResult<
|
||||
unknown extends TData ? TQueryFnData : TData,
|
||||
unknown extends TError ? DefaultError : TError
|
||||
>
|
||||
: T extends {
|
||||
queryFn?:
|
||||
| QueryFunction<infer TQueryFnData, any>
|
||||
| SkipTokenForUseQueries
|
||||
throwOnError?: ThrowOnError<any, infer TError, any, any>
|
||||
}
|
||||
? UseSuspenseQueryResult<
|
||||
TQueryFnData,
|
||||
unknown extends TError ? DefaultError : TError
|
||||
>
|
||||
: // Fallback
|
||||
UseSuspenseQueryResult
|
||||
|
||||
/**
|
||||
* The `queries` array accepted by `useSuspenseQueries`. Recursively unwraps each tuple element so every
|
||||
* entry's `queryFn`/`select` are inferred individually, up to 20 elements. An opaque array (e.g. `unknown[]`)
|
||||
* is returned as-is; a non-tuple array of a known element type, or a tuple past 20 elements, falls back to a
|
||||
* single homogeneous {@link UseSuspenseQueryOptions} type.
|
||||
*
|
||||
* @template T - The type of the `queries` array as written at the call site.
|
||||
* @template TResults - The internal accumulator that this type builds during recursion. It is not meant
|
||||
* to be set explicitly.
|
||||
* @template TDepth - The internal recursion-depth counter, checked against the 20-element limit. It is not
|
||||
* meant to be set explicitly.
|
||||
*/
|
||||
export type SuspenseQueriesOptions<
|
||||
T extends Array<any>,
|
||||
TResults extends Array<any> = [],
|
||||
TDepth extends ReadonlyArray<number> = [],
|
||||
> = TDepth['length'] extends MAXIMUM_DEPTH
|
||||
? Array<UseSuspenseQueryOptions>
|
||||
: T extends []
|
||||
? []
|
||||
: T extends [infer Head]
|
||||
? [...TResults, GetUseSuspenseQueryOptions<Head>]
|
||||
: T extends [infer Head, ...infer Tails]
|
||||
? SuspenseQueriesOptions<
|
||||
[...Tails],
|
||||
[...TResults, GetUseSuspenseQueryOptions<Head>],
|
||||
[...TDepth, 1]
|
||||
>
|
||||
: Array<unknown> extends T
|
||||
? T
|
||||
: // If T is *some* array but we couldn't assign unknown[] to it, then it must hold some known/homogeneous type!
|
||||
// use this to infer the param types in the case of Array.map() argument
|
||||
T extends Array<
|
||||
UseSuspenseQueryOptions<
|
||||
infer TQueryFnData,
|
||||
infer TError,
|
||||
infer TData,
|
||||
infer TQueryKey
|
||||
>
|
||||
>
|
||||
? Array<
|
||||
UseSuspenseQueryOptions<TQueryFnData, TError, TData, TQueryKey>
|
||||
>
|
||||
: // Fallback
|
||||
Array<UseSuspenseQueryOptions>
|
||||
|
||||
/**
|
||||
* The result type returned by `useSuspenseQueries`, when no `combine` is provided. Mirrors
|
||||
* {@link SuspenseQueriesOptions}: each tuple element's result type is inferred individually, up to 20 elements.
|
||||
* A non-tuple array is mapped per-element instead, still inferring each entry individually; only past 20
|
||||
* elements does this fall back to a single homogeneous {@link UseSuspenseQueryResult} type.
|
||||
*
|
||||
* @template T - The type of the `queries` array, as inferred by {@link SuspenseQueriesOptions}.
|
||||
* @template TResults - The internal accumulator that this type builds during recursion. It is not meant
|
||||
* to be set explicitly.
|
||||
* @template TDepth - The internal recursion-depth counter, checked against the 20-element limit. It is not
|
||||
* meant to be set explicitly.
|
||||
*/
|
||||
export type SuspenseQueriesResults<
|
||||
T extends Array<any>,
|
||||
TResults extends Array<any> = [],
|
||||
TDepth extends ReadonlyArray<number> = [],
|
||||
> = TDepth['length'] extends MAXIMUM_DEPTH
|
||||
? Array<UseSuspenseQueryResult>
|
||||
: T extends []
|
||||
? []
|
||||
: T extends [infer Head]
|
||||
? [...TResults, GetUseSuspenseQueryResult<Head>]
|
||||
: T extends [infer Head, ...infer Tails]
|
||||
? SuspenseQueriesResults<
|
||||
[...Tails],
|
||||
[...TResults, GetUseSuspenseQueryResult<Head>],
|
||||
[...TDepth, 1]
|
||||
>
|
||||
: { [K in keyof T]: GetUseSuspenseQueryResult<T[K]> }
|
||||
|
||||
/**
|
||||
* The options for `useSuspenseQueries` are the same as for `useQueries`, except that the top-level `subscribed`
|
||||
* option isn't supported, and each `query` can't have `throwOnError`, `enabled`, or `placeholderData`.
|
||||
*
|
||||
* @param options - The `queries` array to run in Suspense, and an optional `combine` function.
|
||||
* @param queryClient - Use this to provide a custom `QueryClient`. Otherwise, the one from the nearest context
|
||||
* will be used.
|
||||
* @returns The same structure as `useQueries`, except that for each `query`, `data` is guaranteed to be
|
||||
* defined, `isPlaceholderData` is missing, and `status` is either `success` or `error` (with the derived
|
||||
* flags set accordingly).
|
||||
*
|
||||
* Caveat: the component will only re-mount after all queries have finished loading. Hence, if a query has gone
|
||||
* stale in the time it took for all the queries to complete, it will be fetched again at re-mount. To avoid
|
||||
* this, make sure to set a high enough `staleTime`. Cancellation does not work.
|
||||
*
|
||||
* @example
|
||||
* The query error is thrown if a fetch fails and no cached data exists yet, so an error boundary is
|
||||
* required around `<Suspense>`. A failed background refetch instead continues to render the cached data.
|
||||
* Use {@link QueryErrorResetBoundary} to let the user retry after such an error:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'react'
|
||||
* import { ErrorBoundary } from 'react-error-boundary'
|
||||
* import {
|
||||
* QueryErrorResetBoundary,
|
||||
* useSuspenseQueries,
|
||||
* } from '@tanstack/react-query'
|
||||
*
|
||||
* function Posts({ ids }: { ids: Array<number> }) {
|
||||
* // Every result is guaranteed to be defined — no per-query `isPending` check needed.
|
||||
* const postQueries = useSuspenseQueries({
|
||||
* queries: ids.map((id) => ({
|
||||
* queryKey: ['post', id],
|
||||
* queryFn: () => fetchPost(id),
|
||||
* })),
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {postQueries.map((query) => (
|
||||
* <li key={query.data.id}>{query.data.title}</li>
|
||||
* ))}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading posts...</h1>}>
|
||||
* <Posts ids={[1, 2, 3]} />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Several different queries — use `useSuspenseQueries` instead of multiple `useSuspenseQuery` calls, so
|
||||
* they fetch in parallel rather than suspending one after another:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'react'
|
||||
* import { ErrorBoundary } from 'react-error-boundary'
|
||||
* import {
|
||||
* QueryErrorResetBoundary,
|
||||
* useSuspenseQueries,
|
||||
* } from '@tanstack/react-query'
|
||||
*
|
||||
* function Dashboard() {
|
||||
* const [usersQuery, teamsQuery, projectsQuery] = useSuspenseQueries({
|
||||
* queries: [
|
||||
* { queryKey: ['users'], queryFn: fetchUsers },
|
||||
* { queryKey: ['teams'], queryFn: fetchTeams },
|
||||
* { queryKey: ['projects'], queryFn: fetchProjects },
|
||||
* ],
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <UserList users={usersQuery.data} />
|
||||
* <TeamList teams={teamsQuery.data} />
|
||||
* <ProjectList projects={projectsQuery.data} />
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading dashboard...</h1>}>
|
||||
* <Dashboard />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* `combine`s the results into a single boolean, so `Refresh` only re-renders when that boolean changes,
|
||||
* not on every individual query update. This overload is the only one that accepts `combine`:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'react'
|
||||
* import { ErrorBoundary } from 'react-error-boundary'
|
||||
* import {
|
||||
* QueryErrorResetBoundary,
|
||||
* useSuspenseQueries,
|
||||
* } from '@tanstack/react-query'
|
||||
*
|
||||
* function Refresh() {
|
||||
* const anyFetching = useSuspenseQueries({
|
||||
* queries: [
|
||||
* { queryKey: ['users'], queryFn: fetchUsers },
|
||||
* { queryKey: ['teams'], queryFn: fetchTeams },
|
||||
* ],
|
||||
* combine: (results) => results.some((result) => result.isFetching),
|
||||
* })
|
||||
*
|
||||
* return anyFetching ? <span>Refreshing…</span> : null
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading dashboard...</h1>}>
|
||||
* <Refresh />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSuspenseQueries<
|
||||
T extends Array<any>,
|
||||
TCombinedResult = SuspenseQueriesResults<T>,
|
||||
>(
|
||||
options: {
|
||||
/**
|
||||
* An array with query option objects identical to `useSuspenseQuery`.
|
||||
*/
|
||||
queries:
|
||||
| readonly [...SuspenseQueriesOptions<T>]
|
||||
| readonly [...{ [K in keyof T]: GetUseSuspenseQueryOptions<T[K]> }]
|
||||
/**
|
||||
* Use this to combine the results of the queries into a single value. The result will be structurally
|
||||
* shared to be as referentially stable as possible.
|
||||
*/
|
||||
combine?: (result: SuspenseQueriesResults<T>) => TCombinedResult
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): TCombinedResult
|
||||
|
||||
/**
|
||||
* The options for `useSuspenseQueries` are the same as for `useQueries`, except that the top-level `subscribed`
|
||||
* option isn't supported, and each `query` can't have `throwOnError`, `enabled`, or `placeholderData`.
|
||||
*
|
||||
* @param options - The `queries` array to run in Suspense, and an optional `combine` function.
|
||||
* @param queryClient - Use this to provide a custom `QueryClient`. Otherwise, the one from the nearest context
|
||||
* will be used.
|
||||
* @returns The same structure as `useQueries`, except that for each `query`, `data` is guaranteed to be
|
||||
* defined, `isPlaceholderData` is missing, and `status` is either `success` or `error` (with the derived
|
||||
* flags set accordingly).
|
||||
*
|
||||
* Caveat: the component will only re-mount after all queries have finished loading. Hence, if a query has gone
|
||||
* stale in the time it took for all the queries to complete, it will be fetched again at re-mount. To avoid
|
||||
* this, make sure to set a high enough `staleTime`. Cancellation does not work.
|
||||
*
|
||||
* @example
|
||||
* The query error is thrown if a fetch fails and no cached data exists yet, so an error boundary is
|
||||
* required around `<Suspense>`. A failed background refetch instead continues to render the cached data.
|
||||
* Use {@link QueryErrorResetBoundary} to let the user retry after such an error:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'react'
|
||||
* import { ErrorBoundary } from 'react-error-boundary'
|
||||
* import {
|
||||
* QueryErrorResetBoundary,
|
||||
* useSuspenseQueries,
|
||||
* } from '@tanstack/react-query'
|
||||
*
|
||||
* function Posts({ ids }: { ids: Array<number> }) {
|
||||
* // Every result is guaranteed to be defined — no per-query `isPending` check needed.
|
||||
* const postQueries = useSuspenseQueries({
|
||||
* queries: ids.map((id) => ({
|
||||
* queryKey: ['post', id],
|
||||
* queryFn: () => fetchPost(id),
|
||||
* })),
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {postQueries.map((query) => (
|
||||
* <li key={query.data.id}>{query.data.title}</li>
|
||||
* ))}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading posts...</h1>}>
|
||||
* <Posts ids={[1, 2, 3]} />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Several different queries — use `useSuspenseQueries` instead of multiple `useSuspenseQuery` calls, so
|
||||
* they fetch in parallel rather than suspending one after another:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'react'
|
||||
* import { ErrorBoundary } from 'react-error-boundary'
|
||||
* import {
|
||||
* QueryErrorResetBoundary,
|
||||
* useSuspenseQueries,
|
||||
* } from '@tanstack/react-query'
|
||||
*
|
||||
* function Dashboard() {
|
||||
* const [usersQuery, teamsQuery, projectsQuery] = useSuspenseQueries({
|
||||
* queries: [
|
||||
* { queryKey: ['users'], queryFn: fetchUsers },
|
||||
* { queryKey: ['teams'], queryFn: fetchTeams },
|
||||
* { queryKey: ['projects'], queryFn: fetchProjects },
|
||||
* ],
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <UserList users={usersQuery.data} />
|
||||
* <TeamList teams={teamsQuery.data} />
|
||||
* <ProjectList projects={projectsQuery.data} />
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading dashboard...</h1>}>
|
||||
* <Dashboard />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSuspenseQueries<
|
||||
T extends Array<any>,
|
||||
TCombinedResult = SuspenseQueriesResults<T>,
|
||||
>(
|
||||
options: {
|
||||
/**
|
||||
* An array with query option objects identical to `useSuspenseQuery`.
|
||||
*/
|
||||
queries: readonly [...SuspenseQueriesOptions<T>]
|
||||
/**
|
||||
* Use this to combine the results of the queries into a single value. The result will be structurally
|
||||
* shared to be as referentially stable as possible.
|
||||
*/
|
||||
combine?: (result: SuspenseQueriesResults<T>) => TCombinedResult
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): TCombinedResult
|
||||
|
||||
export function useSuspenseQueries(options: any, queryClient?: QueryClient) {
|
||||
return useQueries(
|
||||
{
|
||||
...options,
|
||||
queries: options.queries.map((query: any) => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (query.queryFn === skipToken) {
|
||||
console.error('skipToken is not allowed for useSuspenseQueries')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
suspense: true,
|
||||
throwOnError: defaultThrowOnError,
|
||||
enabled: true,
|
||||
placeholderData: undefined,
|
||||
}
|
||||
}),
|
||||
},
|
||||
queryClient,
|
||||
)
|
||||
}
|
||||
101
packages/react-query/src/useSuspenseQuery.ts
Normal file
101
packages/react-query/src/useSuspenseQuery.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
import { QueryObserver, skipToken } from '@tanstack/query-core'
|
||||
import { useBaseQuery } from './useBaseQuery'
|
||||
import { defaultThrowOnError } from './suspense'
|
||||
import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types'
|
||||
import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core'
|
||||
|
||||
/**
|
||||
* The options for `useSuspenseQuery` are the same as for `useQuery`, except for `throwOnError`, `enabled`, and
|
||||
* `placeholderData` — and `queryFn` may not be `skipToken`, since Suspense hooks can't render a "disabled" state.
|
||||
*
|
||||
* Caveat: cancellation does not work.
|
||||
*
|
||||
* @remarks Multiple `useSuspenseQuery` calls in the same component suspend serially, causing a request
|
||||
* waterfall — each one blocks rendering until it resolves, so the next doesn't even start fetching until then.
|
||||
* Use {@link useSuspenseQueries} instead when you have more than one suspenseful query in a component, so they
|
||||
* fetch in parallel.
|
||||
* @param options - The {@link UseSuspenseQueryOptions} to use — the same options as `useQuery`, minus the ones listed above.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The same object as `useQuery`, except that `data` is guaranteed to be defined, `isPlaceholderData`
|
||||
* is missing, and `status` is either `success` or `error` (with the derived flags set accordingly).
|
||||
*
|
||||
* @example
|
||||
* The query error is thrown if the fetch fails and no cached data exists yet, so an error boundary is
|
||||
* required around `<Suspense>`. A failed background refetch instead continues to render the cached data.
|
||||
* Use {@link QueryErrorResetBoundary} to let the user retry after such an error:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'react'
|
||||
* import { ErrorBoundary } from 'react-error-boundary'
|
||||
* import { QueryErrorResetBoundary, useSuspenseQuery } from '@tanstack/react-query'
|
||||
*
|
||||
* function Posts() {
|
||||
* // `data` is guaranteed to be defined here — no `isPending` check needed.
|
||||
* const { data, isFetching } = useSuspenseQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <h1>Posts {isFetching ? '(refreshing...)' : null}</h1>
|
||||
* <ul>
|
||||
* {data.map((post) => (
|
||||
* <li key={post.id}>{post.title}</li>
|
||||
* ))}
|
||||
* </ul>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading posts...</h1>}>
|
||||
* <Posts />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSuspenseQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UseSuspenseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseSuspenseQueryResult<TData, TError> {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if ((options.queryFn as any) === skipToken) {
|
||||
console.error('skipToken is not allowed for useSuspenseQuery')
|
||||
}
|
||||
}
|
||||
|
||||
return useBaseQuery(
|
||||
{
|
||||
...options,
|
||||
enabled: true,
|
||||
suspense: true,
|
||||
throwOnError: defaultThrowOnError,
|
||||
placeholderData: undefined,
|
||||
},
|
||||
QueryObserver,
|
||||
queryClient,
|
||||
) as UseSuspenseQueryResult<TData, TError>
|
||||
}
|
||||
Reference in New Issue
Block a user