diff --git a/.changeset/experimental-query-handles.md b/.changeset/experimental-query-handles.md new file mode 100644 index 00000000000..848c1cf324e --- /dev/null +++ b/.changeset/experimental-query-handles.md @@ -0,0 +1,6 @@ +--- +'@tanstack/query-core': minor +'@tanstack/react-query': minor +--- + +Add experimental query handles. `experimental_prefetchHandle`, `experimental_ensureHandle` and `experimental_fetchHandle` start a query from a query factory and return a small serializable handle. `experimental_fromHandle` rebuilds the query's options from a handle. `experimental_waterfallHandle` builds a handle for an input known only during render, and the component that reads it starts the query. `experimental_InferQueryHandleData` gives the type of the data a reader gets from a factory or handle. diff --git a/.changeset/query-handles-devtools.md b/.changeset/query-handles-devtools.md new file mode 100644 index 00000000000..2e118a671d4 --- /dev/null +++ b/.changeset/query-handles-devtools.md @@ -0,0 +1,6 @@ +--- +'@tanstack/query-core': minor +'@tanstack/query-devtools': minor +--- + +Show in the devtools how experimental query handles started their queries. In development, `experimental_getQueryStartInfo` returns which handle function started a query, where and when, and whether anything has read it since. Production builds record nothing. The devtools show this, mark queries that a reader of an `experimental_waterfallHandle` handle first fetched, and flag prefetched queries that nothing read. In development, `dehydrate` warns when it leaves out a pending query that a handle function started on the server. diff --git a/docs/config.json b/docs/config.json index 3600d0d5441..2f69e2e6ce0 100644 --- a/docs/config.json +++ b/docs/config.json @@ -343,6 +343,10 @@ "label": "Prefetching & Router Integration", "to": "framework/react/guides/prefetching" }, + { + "label": "Query Handles (experimental)", + "to": "framework/react/guides/query-handles" + }, { "label": "Server Rendering & Hydration", "to": "framework/react/guides/ssr" diff --git a/docs/framework/react/guides/query-handles.md b/docs/framework/react/guides/query-handles.md new file mode 100644 index 00000000000..a338aa048ac --- /dev/null +++ b/docs/framework/react/guides/query-handles.md @@ -0,0 +1,300 @@ +--- +id: query-handles +title: Query Handles (experimental) +--- + +> These APIs are experimental. Their names start with `experimental_`, and they can change in a minor release. + +A route loader or Server Component knows which data a page needs before the page renders. Query handles let it start those queries early and pass small references to the components that read them, so the components don't repeat the query's options. + +A handle is a plain object with the query's key and its input. It is serializable, so a loader or Server Component can return it. + +## Query factories + +Handles come from query factories: functions of one input (or none) that return query options. + +[//]: # 'Factories' + +```tsx +import { queryOptions } from '@tanstack/react-query' + +export const postQuery = (input: { id: string }) => + queryOptions({ + queryKey: ['post', input.id] as const, + queryFn: () => fetchPost(input.id), + staleTime: 60 * 1000, + }) + +export const viewerQuery = () => + queryOptions({ + queryKey: ['viewer'] as const, + queryFn: fetchViewer, + }) +``` + +[//]: # 'Factories' + +A handle's type comes from its factory's key. Declare keys with `as const`: two factories with the same input and data then still produce different handle types, and passing one factory's handle to another is a type error. + +## Starting queries + +Three functions start a query and return its handle. They take the `QueryClient` first, then the factory, then the factory's input: + +| Function | Waits for | +| ----------------------------- | ----------------------------------- | +| `experimental_prefetchHandle` | nothing | +| `experimental_ensureHandle` | any data, cached data even if stale | +| `experimental_fetchHandle` | fresh data | + +All three return the same plain handle, without the query's data. When an awaited handle resolves, the cache has the query's data. + +All three use the query's `staleTime` like `queryClient.query`: they start a fetch only when the cache has no fresh data, and `experimental_ensureHandle` refetches stale data in the background. + +`experimental_prefetchHandle` doesn't wait and doesn't reject: fetch errors show up where a component reads the handle. The other two reject when the query fails without data, so a loader can redirect or show an error page. + +[//]: # 'Loader' + +```tsx +import { + experimental_ensureHandle, + experimental_prefetchHandle, +} from '@tanstack/react-query' + +export async function loader({ params }) { + return { + // The page can't render without the viewer, so wait for it + viewer: await experimental_ensureHandle(queryClient, viewerQuery), + // Start the post without waiting, and stream it in + post: experimental_prefetchHandle(queryClient, postQuery, { + id: params.id, + }), + } +} +``` + +[//]: # 'Loader' + +## Reading handles + +`experimental_fromHandle(factory, handle)` rebuilds the query's options from its handle. Pass them to `useQuery`, `useSuspenseQuery`, `useSuspenseInfiniteQuery` or any other API that takes query options, and spread them to add read-site options like `select`: + +[//]: # 'Reading' + +```tsx +import { + experimental_fromHandle, + useSuspenseQuery, + type experimental_QueryHandle, +} from '@tanstack/react-query' + +function Post(props: { post: experimental_QueryHandle }) { + const { data } = useSuspenseQuery({ + ...experimental_fromHandle(postQuery, props.post), + select: (post) => post.title, + }) + return

{data}

+} +``` + +[//]: # 'Reading' + +The factory types `select`'s argument, as with any `queryOptions` result. A `select` in the factory works too: readers get the selected type, and a read-site `select` replaces it. Keep options that decide how the query fetches or how long its data stays fresh, like `queryFn` or `staleTime`, on the factory, so the loader and the component agree on them. + +Readers get the query's data from the cache. To get defined `data`, read the handle with `useSuspenseQuery`, which waits for the data. To type a prop that takes that data, use `experimental_InferQueryHandleData`, which gives the same type as `useSuspenseQuery`'s `data`. `useQuery` types `data` as possibly `undefined`, as usual, even for a handle a loader waited for: the cache may have lost the data since, for example after `queryClient.clear()`. + +## Waterfalls: `experimental_waterfallHandle` + +Sometimes you only know the input during render, like a search term or a page a user clicks to. `experimental_waterfallHandle` builds the handle for such an input. It doesn't start the query: the component that reads the handle does, when it renders. That makes it a [request waterfall](./request-waterfalls.md), and the name keeps that visible. It is a plain function, not a hook, so you can call it anywhere. The devtools mark these queries with a `waterfall` badge. + +Pass it an input: + +[//]: # 'WaterfallInput' + +```tsx +function Search(props: { term: string }) { + const results = experimental_waterfallHandle(searchQuery, { + term: props.term, + }) + const { data } = useQuery(experimental_fromHandle(searchQuery, results)) +} +``` + +[//]: # 'WaterfallInput' + +Or switch a handle a loader passed down to a new input: + +[//]: # 'WaterfallSwitch' + +```tsx +function Posts(props: { posts: experimental_QueryHandle }) { + const [page, setPage] = React.useState(1) + const posts = experimental_waterfallHandle( + postsQuery, + props.posts, + (input) => ({ + ...input, + page, + }), + ) + const { data } = useQuery({ + ...experimental_fromHandle(postsQuery, posts), + placeholderData: keepPreviousData, + }) +} +``` + +[//]: # 'WaterfallSwitch' + +While the switched input maps to the handle's own query, it returns that handle as is, so the first page reads what the loader started. That also keeps the old input when only fields outside the key change, so put everything the `queryFn` reads in the key. Prefer putting such inputs in the URL so the loader can start them, and use `experimental_waterfallHandle` when that isn't practical. + +The query starts when its reader renders, like any other `useQuery` or `useSuspenseQuery`. `useQuery` doesn't fetch during a server render, while `useSuspenseQuery` does. + +## Server rendering + +Queries you start with handles work with the [hydration APIs](./advanced-ssr.md) like any other query. + +### Streaming pending queries + +`dehydrate` only includes successful queries by default. To stream a query that `experimental_prefetchHandle` started and that is still pending, opt in through `shouldDehydrateQuery`. A pending query dehydrates to a promise, so the page needs a transport that can send promises, like React Server Components, TanStack Start or React Router: + +[//]: # 'DehydratePending' + +```tsx +import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query' + +const queryClient = new QueryClient({ + defaultOptions: { + dehydrate: { + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || query.state.status === 'pending', + }, + }, +}) +``` + +[//]: # 'DehydratePending' + +In development, `dehydrate` warns once when a server render leaves out a pending query that a handle function started and no earlier `dehydrate` call sent, because the browser would fetch it again. + +### Server `staleTime` + +Queries started on the server follow the same `staleTime` rules as in the browser. The data a server render sends along is as old as the server fetch, and when the browser reads it, a query with the default `staleTime: 0` already counts as stale. The component that reads it refetches on mount, so each request runs twice: once on the server and once in the browser. + +[//]: # 'StaleTimeDoubleFetch' + +```tsx +const postQuery = (input: { id: string }) => + queryOptions({ + queryKey: ['post', input.id] as const, + queryFn: () => fetchPost(input.id), + // No staleTime: it defaults to 0 + }) + +// On the server: fetches the post, and sends it along with the page +const post = await experimental_fetchHandle(queryClient, postQuery, { id }) + +// In the browser: the post is already stale, so this fetches it again on mount +const { data } = useQuery(experimental_fromHandle(postQuery, post)) +``` + +[//]: # 'StaleTimeDoubleFetch' + +The fix is a `staleTime` above 0, either on the factory or as a default of the `QueryClient`. Pick one that covers the time between the server fetch and the browser's first render: + +[//]: # 'StaleTimeFix' + +```tsx +const postQuery = (input: { id: string }) => + queryOptions({ + queryKey: ['post', input.id] as const, + queryFn: () => fetchPost(input.id), + staleTime: 60 * 1000, + }) + +// Or, for every query: +const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 60 * 1000 } }, +}) +``` + +[//]: # 'StaleTimeFix' + +Set `staleTime` on the factory rather than at the read site, so the server and the browser use the same value. + +### Nested dehydration without sending a query twice + +With Server Components, each component that starts queries can render its own `HydrationBoundary`. When two of them share a query, both dehydrate it, and the page sends its data twice. To send each query once per request, track the hashes of the queries you've already sent and skip them in `shouldDehydrateQuery`. + +The tracker must belong to one request. Create it with the request's `QueryClient`, or with React's `cache()` in Server Components, which gives each request its own copy: + +[//]: # 'NestedDehydrate' + +```tsx +// app/get-query-client.ts +import { cache } from 'react' +import { + QueryClient, + defaultShouldDehydrateQuery, + dehydrate, +} from '@tanstack/react-query' + +export const getQueryClient = cache( + () => + new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000 } } }), +) + +// One set per request, like the QueryClient +const getSentQueryHashes = cache(() => new Set()) + +export function dehydrateUnsent() { + const sent = getSentQueryHashes() + return dehydrate(getQueryClient(), { + shouldDehydrateQuery: (query) => { + if (sent.has(query.queryHash)) return false + const include = + defaultShouldDehydrateQuery(query) || query.state.status === 'pending' + if (include) sent.add(query.queryHash) + return include + }, + }) +} +``` + +[//]: # 'NestedDehydrate' + +Outside Server Components, key the tracker by the request's client, for example with a `WeakMap>`, or create it next to the client in your request handler. + +Don't create the tracker at module level: + +[//]: # 'NestedDehydrateDont' + +```tsx +// Don't do this: one set for every request the server handles +const sent = new Set() + +export function dehydrateUnsent(queryClient: QueryClient) { + return dehydrate(queryClient, { + shouldDehydrateQuery: (query) => { + if (sent.has(query.queryHash)) return false + sent.add(query.queryHash) + return true + }, + }) +} +``` + +[//]: # 'NestedDehydrateDont' + +Every request the server handles shares the module. Once one request has sent a query, every later request for any user skips it, so their pages arrive without its data and fetch it again in the browser. The set also grows for as long as the server runs. + +Each query goes out once, with the state it has when `dehydrate` first includes it. If it changes later in the same request, the change doesn't reach the browser. + +## Devtools + +In development, the query devtools show how each handle's query started: + +- **Started by** shows the function that started the query and whether it ran on the server or in the browser. Queries that `hydrate` restores from a server render keep the server's record. +- A **waterfall** badge marks queries that a component first fetched by reading a handle from `experimental_waterfallHandle`. +- **Prefetched, never read** marks queries that a handle function started and no component has read since. That usually means a loader prefetches data the page no longer uses. + +Core only records this in development, so the devtools show none of it in a production build. diff --git a/packages/query-core/src/__tests__/handles.test.tsx b/packages/query-core/src/__tests__/handles.test.tsx new file mode 100644 index 00000000000..5784262a692 --- /dev/null +++ b/packages/query-core/src/__tests__/handles.test.tsx @@ -0,0 +1,486 @@ +import { describe, expect, it, vi } from 'vitest' +import { + clearOnDispose, + createKey, + fakeTimers, + sleep, + subscribe, +} from '@tanstack/query-test-utils' +import { + QueryClient, + QueryObserver, + environmentManager, + experimental_ensureHandle, + experimental_fetchHandle, + experimental_fromHandle, + experimental_prefetchHandle, + experimental_waterfallHandle, + noop, +} from '..' + +const isServer = environmentManager.isServer() + +const serverEnvironment = (): Disposable => { + environmentManager.setIsServer(() => true) + return { + [Symbol.dispose]: () => { + environmentManager.setIsServer(() => isServer) + }, + } +} + +describe('query handles', () => { + const createPostQuery = ( + queryFn = (id: string) => sleep(10).then(() => id), + ) => { + const key = createKey() + return (input: { id: string }) => ({ + queryKey: [...key, input.id], + queryFn: () => queryFn(input.id), + }) + } + + describe('experimental_prefetchHandle', () => { + it('should start the query and return a serializable handle', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + + expect(handle).toStrictEqual({ + queryKey: postQuery({ id: '1' }).queryKey, + input: { id: '1' }, + }) + expect(JSON.parse(JSON.stringify(handle))).toStrictEqual(handle) + expect( + queryClient.getQueryState(postQuery({ id: '1' }).queryKey)?.fetchStatus, + ).toBe('fetching') + }) + + it('should give a factory without input an undefined input', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const key = createKey() + const viewerQuery = () => ({ + queryKey: key, + queryFn: () => 'viewer', + }) + + const handle = experimental_prefetchHandle(queryClient, viewerQuery) + + expect(handle).toStrictEqual({ queryKey: key, input: undefined }) + }) + + it('should not throw when the query fails', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery(() => Promise.reject(new Error('no'))) + + experimental_prefetchHandle(queryClient, postQuery, { id: '1' }) + await vi.advanceTimersByTimeAsync(0) + + expect( + queryClient.getQueryState(postQuery({ id: '1' }).queryKey)?.status, + ).toBe('error') + }) + + it('should not refetch fresh data', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const key = createKey() + const queryFn = vi.fn<() => Promise>(() => Promise.resolve('new')) + const postQuery = (input: { id: string }) => ({ + queryKey: [...key, input.id], + queryFn, + staleTime: Infinity, + }) + queryClient.setQueryData(postQuery({ id: '1' }).queryKey, 'cached') + + experimental_prefetchHandle(queryClient, postQuery, { id: '1' }) + + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should start an infinite query for infinite options', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const key = createKey() + const feedQuery = (input: { size: number }) => ({ + queryKey: [...key, input.size], + queryFn: ({ pageParam }: { pageParam: number }) => pageParam * 10, + initialPageParam: 1, + getNextPageParam: (lastPage: number) => lastPage + 1, + }) + + experimental_prefetchHandle(queryClient, feedQuery, { size: 1 }) + await vi.advanceTimersByTimeAsync(0) + + expect(queryClient.getQueryData([...key, 1])).toStrictEqual({ + pages: [10], + pageParams: [1], + }) + }) + + it('should start an infinite query for infinite options without getNextPageParam', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const key = createKey() + const feedQuery = (input: { size: number }) => ({ + queryKey: [...key, input.size], + queryFn: ({ pageParam }: { pageParam: number }) => pageParam * 10, + initialPageParam: 1, + }) + + experimental_prefetchHandle(queryClient, feedQuery, { size: 1 }) + await vi.advanceTimersByTimeAsync(0) + + expect(queryClient.getQueryData([...key, 1])).toStrictEqual({ + pages: [10], + pageParams: [1], + }) + }) + }) + + describe('experimental_ensureHandle', () => { + it('should wait for the data and return a plain handle', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + + const promise = experimental_ensureHandle(queryClient, postQuery, { + id: '1', + }) + await vi.advanceTimersByTimeAsync(10) + const handle = await promise + + expect(handle).toStrictEqual({ + queryKey: postQuery({ id: '1' }).queryKey, + input: { id: '1' }, + }) + expect(queryClient.getQueryData(handle.queryKey)).toBe('1') + }) + + it('should load the pages an infinite factory asks for', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const key = createKey() + const feedQuery = (input: { pages: number }) => ({ + queryKey: key, + queryFn: ({ pageParam }: { pageParam: number }) => pageParam * 10, + initialPageParam: 1, + getNextPageParam: (lastPage: number) => lastPage / 10 + 1, + pages: input.pages, + }) + + await experimental_ensureHandle(queryClient, feedQuery, { pages: 3 }) + + expect(queryClient.getQueryData(key)).toStrictEqual({ + pages: [10, 20, 30], + pageParams: [1, 2, 3], + }) + }) + + it('should resolve with stale cached data at once and refetch it in the background', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const queryFn = vi.fn<(id: string) => Promise>((id) => + sleep(10).then(() => `${id}-new`), + ) + const postQuery = createPostQuery(queryFn) + queryClient.setQueryData(postQuery({ id: '1' }).queryKey, 'cached') + + await experimental_ensureHandle(queryClient, postQuery, { id: '1' }) + + expect(queryClient.getQueryData(postQuery({ id: '1' }).queryKey)).toBe( + 'cached', + ) + expect(queryFn).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryData(postQuery({ id: '1' }).queryKey)).toBe( + '1-new', + ) + }) + + it('should not reject when a background refetch of cached data fails', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery(() => Promise.reject(new Error('no'))) + queryClient.setQueryData(postQuery({ id: '1' }).queryKey, 'cached') + + const handle = await experimental_ensureHandle(queryClient, postQuery, { + id: '1', + }) + await vi.advanceTimersByTimeAsync(0) + + expect(queryClient.getQueryData(handle.queryKey)).toBe('cached') + }) + + it('should reject when the query has no data and fails', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery(() => Promise.reject(new Error('no'))) + + await expect( + experimental_ensureHandle(queryClient, postQuery, { id: '1' }), + ).rejects.toThrow('no') + }) + }) + + describe('experimental_fetchHandle', () => { + it('should wait for fresh data when the cached data is stale', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery((id) => + sleep(10).then(() => `${id}-new`), + ) + queryClient.setQueryData(postQuery({ id: '1' }).queryKey, 'cached') + + const promise = experimental_fetchHandle(queryClient, postQuery, { + id: '1', + }) + await vi.advanceTimersByTimeAsync(10) + const handle = await promise + + expect(queryClient.getQueryData(handle.queryKey)).toBe('1-new') + }) + + it('should reject when the fetch fails', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery(() => Promise.reject(new Error('no'))) + + await expect( + experimental_fetchHandle(queryClient, postQuery, { id: '1' }), + ).rejects.toThrow('no') + }) + + it('should load the pages an infinite factory asks for', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const key = createKey() + const feedQuery = (input: { pages: number }) => ({ + queryKey: key, + queryFn: ({ pageParam }: { pageParam: number }) => pageParam * 10, + initialPageParam: 1, + getNextPageParam: (lastPage: number) => lastPage / 10 + 1, + pages: input.pages, + }) + queryClient.setQueryData(key, { pages: [10], pageParams: [1] }) + + await experimental_fetchHandle(queryClient, feedQuery, { pages: 2 }) + + expect(queryClient.getQueryData(key)).toStrictEqual({ + pages: [10, 20], + pageParams: [1, 2], + }) + }) + }) + + describe('on a server client', () => { + it('should follow staleTime as a browser client does', async () => { + using _timers = fakeTimers() + using _server = serverEnvironment() + using queryClient = clearOnDispose(new QueryClient()) + const queryFn = vi.fn<(id: string) => Promise>((id) => + sleep(10).then(() => id), + ) + const postQuery = createPostQuery(queryFn) + + experimental_prefetchHandle(queryClient, postQuery, { id: '1' }) + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) + + // staleTime defaults to 0, so the data is already stale + await experimental_ensureHandle(queryClient, postQuery, { id: '1' }) + expect(queryFn).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(10) + + const fetched = experimental_fetchHandle(queryClient, postQuery, { + id: '1', + }) + expect(queryFn).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(10) + await expect(fetched).resolves.toStrictEqual({ + queryKey: postQuery({ id: '1' }).queryKey, + input: { id: '1' }, + }) + }) + + it('should not refetch data that is fresh by staleTime', async () => { + using _timers = fakeTimers() + using _server = serverEnvironment() + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { queries: { staleTime: 60 * 1000 } }, + }), + ) + const queryFn = vi.fn<(id: string) => Promise>((id) => + sleep(10).then(() => id), + ) + const postQuery = createPostQuery(queryFn) + + const first = experimental_fetchHandle(queryClient, postQuery, { + id: '1', + }) + await vi.advanceTimersByTimeAsync(10) + await first + experimental_prefetchHandle(queryClient, postQuery, { id: '1' }) + await experimental_ensureHandle(queryClient, postQuery, { id: '1' }) + await experimental_fetchHandle(queryClient, postQuery, { id: '1' }) + + expect(queryFn).toHaveBeenCalledTimes(1) + }) + }) + + describe('experimental_fromHandle', () => { + it("should rebuild the factory's options for the handle's input", () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + + const options = experimental_fromHandle(postQuery, handle) + + expect(options).toStrictEqual({ + ...postQuery({ id: '1' }), + queryFn: expect.any(Function), + }) + expect(options).not.toHaveProperty('initialData') + }) + + it('should take read-site options spread over it', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + await vi.advanceTimersByTimeAsync(10) + + const observer = new QueryObserver(queryClient, { + ...experimental_fromHandle(postQuery, handle), + select: (data) => String(data).length, + }) + + expect(observer.getCurrentResult().data).toBe(1) + }) + + it('should add no initialData for an awaited handle', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const promise = experimental_fetchHandle(queryClient, postQuery, { + id: '1', + }) + await vi.advanceTimersByTimeAsync(10) + const handle = await promise + + const options = experimental_fromHandle(postQuery, handle) + + expect(options).not.toHaveProperty('initialData') + expect(options).not.toHaveProperty('initialDataUpdatedAt') + }) + + it('should leave a reader with no data after the cache is cleared', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const promise = experimental_fetchHandle(queryClient, postQuery, { + id: '1', + }) + await vi.advanceTimersByTimeAsync(10) + const handle = await promise + const options = { + ...experimental_fromHandle(postQuery, handle), + enabled: false, + } + const observer = new QueryObserver(queryClient, options) + using _subscription = subscribe(observer, noop) + + queryClient.clear() + // The reader renders again + observer.setOptions(options) + + expect(queryClient.getQueryData(handle.queryKey)).toBeUndefined() + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + data: undefined, + }) + }) + }) + + describe('experimental_waterfallHandle', () => { + it('should build a handle for the input without starting the query', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const queryFn = vi.fn<(id: string) => Promise>((id) => + sleep(10).then(() => id), + ) + const postQuery = createPostQuery(queryFn) + + const handle = experimental_waterfallHandle(postQuery, { id: '1' }) + + expect(handle).toStrictEqual({ + queryKey: postQuery({ id: '1' }).queryKey, + input: { id: '1' }, + }) + expect(queryFn).not.toHaveBeenCalled() + expect(queryClient.getQueryCache().getAll()).toHaveLength(0) + }) + + it('should give a factory without input an undefined input', () => { + const key = createKey() + const viewerQuery = () => ({ queryKey: key, queryFn: () => 'viewer' }) + + expect(experimental_waterfallHandle(viewerQuery)).toStrictEqual({ + queryKey: key, + input: undefined, + }) + }) + + it('should return the same handle while the new input maps to its query', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const key = createKey() + const postQuery = (input: { id: string; locale?: string }) => ({ + queryKey: [...key, input.id], + queryFn: () => input.id, + }) + const previous = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + + expect( + experimental_waterfallHandle(postQuery, previous, (input) => ({ + ...input, + locale: 'en', + })), + ).toBe(previous) + expect( + experimental_waterfallHandle(postQuery, previous, () => ({ id: '2' })), + ).toStrictEqual({ + queryKey: [...key, '2'], + input: { id: '2' }, + }) + }) + + it("should compare keys with the factory's queryKeyHashFn", () => { + const key = createKey() + const postQuery = (input: { id: string }) => ({ + queryKey: [...key, input.id], + queryFn: () => input.id, + queryKeyHashFn: () => 'same', + }) + const previous = experimental_waterfallHandle(postQuery, { id: '1' }) + + expect( + experimental_waterfallHandle(postQuery, previous, () => ({ id: '2' })), + ).toBe(previous) + }) + }) +}) diff --git a/packages/query-core/src/__tests__/hydration.test-d.tsx b/packages/query-core/src/__tests__/hydration.test-d.tsx index ae76ed7fda2..ae349d78df0 100644 --- a/packages/query-core/src/__tests__/hydration.test-d.tsx +++ b/packages/query-core/src/__tests__/hydration.test-d.tsx @@ -275,6 +275,7 @@ describe('hydration', () => { | 'promise' | 'meta' | 'queryType' + | 'experimental_start' >() }) }) diff --git a/packages/query-core/src/__tests__/queryStartInfo.test.tsx b/packages/query-core/src/__tests__/queryStartInfo.test.tsx new file mode 100644 index 00000000000..2859dd40116 --- /dev/null +++ b/packages/query-core/src/__tests__/queryStartInfo.test.tsx @@ -0,0 +1,369 @@ +import { describe, expect, it, vi } from 'vitest' +import { + clearOnDispose, + createKey, + fakeTimers, + sleep, + subscribe, +} from '@tanstack/query-test-utils' +import { + QueryClient, + QueryObserver, + defaultShouldDehydrateQuery, + dehydrate, + environmentManager, + experimental_ensureHandle, + experimental_fromHandle, + experimental_getQueryStartInfo, + experimental_prefetchHandle, + experimental_waterfallHandle, + hydrate, + isServer, + noop, +} from '..' +import type { DehydratedState, QueryCacheNotifyEvent } from '..' + +const serverEnvironment = (): Disposable => { + environmentManager.setIsServer(() => true) + return { + [Symbol.dispose]: () => { + environmentManager.setIsServer(() => isServer) + }, + } +} + +const productionEnvironment = (): Disposable => { + vi.stubEnv('NODE_ENV', 'production') + return { + [Symbol.dispose]: () => { + vi.unstubAllEnvs() + }, + } +} + +const getQuery = ( + queryClient: QueryClient, + queryKey: ReadonlyArray, +) => { + const query = queryClient.getQueryCache().find({ queryKey, exact: true }) + if (!query) { + throw new Error('no query') + } + return query +} + +const createPostQuery = () => { + const key = createKey() + return (input: { id: string }) => ({ + queryKey: [...key, input.id], + queryFn: () => sleep(10).then(() => input.id), + }) +} + +describe('query start info', () => { + it('should record how a query started and when an observer reads it', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + const query = getQuery(queryClient, handle.queryKey) + + expect(experimental_getQueryStartInfo(query)).toStrictEqual({ + verb: 'prefetchHandle', + environment: 'client', + startedAt: Date.now(), + observed: false, + }) + + using _subscription = subscribe( + new QueryObserver( + queryClient, + experimental_fromHandle(postQuery, handle), + ), + noop, + ) + + expect(experimental_getQueryStartInfo(query)?.observed).toBe(true) + }) + + it('should record the first fetch of a reader of an experimental_waterfallHandle handle', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_waterfallHandle(postQuery, { id: '1' }) + + expect(handle).toStrictEqual({ + queryKey: postQuery({ id: '1' }).queryKey, + input: { id: '1' }, + }) + expect(JSON.parse(JSON.stringify(handle))).toStrictEqual(handle) + // React Server Components reject props with symbol properties + expect(Object.getOwnPropertySymbols(handle)).toHaveLength(0) + + const startedAt = Date.now() + const observer = new QueryObserver( + queryClient, + experimental_fromHandle(postQuery, handle), + ) + using _subscription = subscribe(observer, noop) + const query = getQuery(queryClient, handle.queryKey) + + expect(experimental_getQueryStartInfo(query)).toStrictEqual({ + verb: 'waterfallHandle', + environment: 'client', + startedAt, + observed: true, + }) + + await vi.advanceTimersByTimeAsync(20) + void observer.refetch() + + expect(experimental_getQueryStartInfo(query)?.startedAt).toBe(startedAt) + }) + + it('should keep the record of a loader handle that experimental_waterfallHandle switches back to', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + const switched = experimental_waterfallHandle(postQuery, handle, () => ({ + id: '1', + })) + + using _subscription = subscribe( + new QueryObserver( + queryClient, + experimental_fromHandle(postQuery, switched), + ), + noop, + ) + + expect( + experimental_getQueryStartInfo(getQuery(queryClient, handle.queryKey)) + ?.verb, + ).toBe('prefetchHandle') + }) + + it('should notify the cache when a handle restarts a query on fresh data', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose( + new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }), + ) + const postQuery = createPostQuery() + experimental_prefetchHandle(queryClient, postQuery, { id: '1' }) + await vi.advanceTimersByTimeAsync(10) + const query = getQuery(queryClient, postQuery({ id: '1' }).queryKey) + const listener = vi.fn<(event: QueryCacheNotifyEvent) => void>() + using _subscription = subscribe(queryClient.getQueryCache(), listener) + + await vi.advanceTimersByTimeAsync(5) + const handle = await experimental_ensureHandle(queryClient, postQuery, { + id: '1', + }) + + expect(queryClient.getQueryData(handle.queryKey)).toBe('1') + expect(experimental_getQueryStartInfo(query)).toMatchObject({ + verb: 'ensureHandle', + startedAt: Date.now(), + }) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ type: 'observerResultsUpdated', query }), + ) + }) + + it('should keep the start info when resetQueries resets the query', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + await vi.advanceTimersByTimeAsync(10) + + await queryClient.resetQueries({ queryKey: handle.queryKey }) + + expect( + experimental_getQueryStartInfo(getQuery(queryClient, handle.queryKey)), + ).toMatchObject({ verb: 'prefetchHandle' }) + }) + + it('should not record anything in production', async () => { + using _timers = fakeTimers() + using _production = productionEnvironment() + using queryClient = clearOnDispose(new QueryClient()) + const key = createKey() + const queryFn = () => sleep(10).then(() => 'data') + const postQuery = (input: { id: string }) => ({ + queryKey: [...key, input.id], + queryFn, + }) + + const prefetched = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + const waterfall = experimental_waterfallHandle(postQuery, { id: '2' }) + const options = experimental_fromHandle(postQuery, waterfall) + using _subscription = subscribe( + new QueryObserver(queryClient, options), + noop, + ) + await vi.advanceTimersByTimeAsync(10) + + expect(options.queryFn).toBe(queryFn) + expect( + experimental_getQueryStartInfo( + getQuery(queryClient, prefetched.queryKey), + ), + ).toBeUndefined() + expect( + experimental_getQueryStartInfo(getQuery(queryClient, waterfall.queryKey)), + ).toBeUndefined() + }) + + it('should not watch the cache in production', () => { + using _timers = fakeTimers() + using _production = productionEnvironment() + using queryClient = clearOnDispose(new QueryClient()) + + experimental_prefetchHandle(queryClient, createPostQuery(), { id: '1' }) + + expect(queryClient.getQueryCache().hasListeners()).toBe(false) + }) + + it('should not record queries that other APIs start', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + + void queryClient.query(postQuery({ id: '1' })).catch(noop) + await vi.advanceTimersByTimeAsync(10) + + expect( + experimental_getQueryStartInfo( + getQuery(queryClient, postQuery({ id: '1' }).queryKey), + ), + ).toBeUndefined() + expect(dehydrate(queryClient).queries[0]).not.toHaveProperty( + 'experimental_start', + ) + }) + + it('should carry the start info from the server to the client in development', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + let dehydrated: DehydratedState + { + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + experimental_prefetchHandle(serverClient, postQuery, { id: '1' }) + dehydrated = dehydrate(serverClient, { + shouldDehydrateQuery: () => true, + }) + } + + hydrate(queryClient, dehydrated) + + expect( + experimental_getQueryStartInfo( + getQuery(queryClient, postQuery({ id: '1' }).queryKey), + ), + ).toStrictEqual({ + verb: 'prefetchHandle', + environment: 'server', + startedAt: Date.now(), + observed: false, + }) + }) + + it('should warn when a server render leaves out a pending query a handle started', () => { + using _timers = fakeTimers() + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + experimental_prefetchHandle(serverClient, createPostQuery(), { id: '1' }) + + dehydrate(serverClient) + + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[0]).toContain('experimental_prefetchHandle') + }) + + it('should warn once per query', () => { + using _timers = fakeTimers() + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + experimental_prefetchHandle(serverClient, createPostQuery(), { id: '1' }) + + dehydrate(serverClient) + dehydrate(serverClient) + + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('should not warn about a pending query that an earlier dehydrate sent', () => { + using _timers = fakeTimers() + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + experimental_prefetchHandle(serverClient, createPostQuery(), { id: '1' }) + const sent = new Set() + const dehydrateUnsent = () => + dehydrate(serverClient, { + shouldDehydrateQuery: (query) => { + if (sent.has(query.queryHash)) return false + const include = + defaultShouldDehydrateQuery(query) || + query.state.status === 'pending' + if (include) sent.add(query.queryHash) + return include + }, + }) + + expect(dehydrateUnsent().queries).toHaveLength(1) + expect(dehydrateUnsent().queries).toHaveLength(0) + expect(warn).not.toHaveBeenCalled() + }) + + it('should not warn when the browser dehydrates a hydrated pending query', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + let dehydrated: DehydratedState + { + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + experimental_prefetchHandle(serverClient, createPostQuery(), { id: '1' }) + dehydrated = dehydrate(serverClient, { + shouldDehydrateQuery: () => true, + }) + } + + hydrate(queryClient, dehydrated) + // Like persistQueryClient saving the cache + dehydrate(queryClient) + + expect(warn).not.toHaveBeenCalled() + }) + + it('should not warn about pending queries that other APIs or the browser started', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + const postQuery = createPostQuery() + experimental_prefetchHandle(queryClient, postQuery, { id: '1' }) + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + void serverClient.query(postQuery({ id: '2' })).catch(noop) + + dehydrate(queryClient) + dehydrate(serverClient) + + expect(warn).not.toHaveBeenCalled() + }) +}) diff --git a/packages/query-core/src/handles.ts b/packages/query-core/src/handles.ts new file mode 100644 index 00000000000..b75d56e09b4 --- /dev/null +++ b/packages/query-core/src/handles.ts @@ -0,0 +1,545 @@ +import { environmentManager } from './environmentManager' +import { hashQueryKeyByOptions, noop } from './utils' +import type { QueryCache } from './queryCache' +import type { QueryClient } from './queryClient' +import type { Query } from './query' +import type { + InferDataFromTag, + QueryFunctionContext, + QueryKey, + QueryKeyHashFunction, +} from './types' + +// TYPES + +/** + * The options a query factory returns, usually from `queryOptions` or `infiniteQueryOptions`. + */ +interface QueryFactoryOptions { + queryKey: QueryKey + queryKeyHashFn?: QueryKeyHashFunction +} + +/** + * A function of zero or one argument that returns a query's options, usually with `queryOptions` + * or `infiniteQueryOptions`. Its argument is the query's input, which handles carry. + */ +export type QueryHandleFactory = (input?: any) => QueryFactoryOptions + +/** + * The input of a query factory: its first parameter, or `undefined` if it takes none. + */ +type QueryHandleInput = + Parameters extends [] ? undefined : Parameters[0] + +/** + * The arguments the handle functions take after the factory: its input, if it takes one. A + * factory's other parameters are left out, because a handle only carries the input, so + * `experimental_fromHandle` couldn't pass them on. + */ +type QueryHandleArgs = + Parameters extends [] + ? [] + : Parameters extends [infer TInput, ...Array] + ? [input: TInput] + : [input?: Parameters[0]] + +type PlainTuple< + TQueryKey extends ReadonlyArray, + TRequired extends Array = [], + TOptional extends Array = [], +> = [...TRequired, ...TOptional]['length'] extends TQueryKey['length'] + ? [...TRequired, ...TOptional, unknown]['length'] extends TQueryKey['length'] + ? PlainTuple< + TQueryKey, + TRequired, + [...TOptional, TQueryKey[[...TRequired, ...TOptional]['length']]] + > + : [...TRequired, ...Partial] + : PlainTuple + +type PlainQueryKey> = + number extends TQueryKey['length'] + ? ReadonlyArray + : Readonly> + +/** + * The `queryKey` of a factory's query as a plain readonly tuple, without the data tag that + * `queryOptions` adds, so a handle stays a plain serializable object for routers that check + * loader data. + */ +type QueryHandleKey = PlainQueryKey< + ReturnType['queryKey'] +> + +/** + * A small serializable reference to a query, from `experimental_prefetchHandle`, + * `experimental_ensureHandle`, `experimental_fetchHandle` or `experimental_waterfallHandle`. Pass + * it to the component that reads the query, and rebuild the query's options there with + * `experimental_fromHandle(factory, handle)`. It carries no data: readers get the query's data + * from the cache. + * + * Handles of two factories only mix when their keys have the same type. Declare keys with + * `as const` to keep factories with the same input and data apart. + */ +export interface QueryHandle< + TFactory extends QueryHandleFactory = QueryHandleFactory, +> { + /** + * The key of the query the handle refers to. + */ + readonly queryKey: QueryHandleKey + /** + * The query's input: the argument its factory takes. + */ + readonly input: QueryHandleInput +} + +/** + * The options `experimental_fromHandle` returns: the factory's options for the handle's input. + */ +export type FromHandleOptions = + ReturnType + +type FactoryData = 'select' extends keyof TOptions + ? TOptions extends { select?: (data: any) => infer TData } + ? TData + : never + : TOptions extends { queryKey: QueryKey } + ? InferDataFromTag + : unknown + +/** + * The data a reader of a factory's query gets, the same as `useSuspenseQuery`'s `data`: what the + * factory's `select` returns, or else the `queryFn`'s data, which is `InfiniteData` for an infinite + * factory. Use it to type a prop that takes the data. Pass a factory type or a handle type. + * @example + * ```ts + * function PostTitle(props: { post: experimental_InferQueryHandleData }) { + * return

{props.post.title}

+ * } + * ``` + */ +export type InferQueryHandleData< + T extends QueryHandleFactory | QueryHandle, +> = T extends QueryHandleFactory + ? FactoryData> + : T extends QueryHandle + ? FactoryData> + : never + +/** + * How a handle API last started a query, for the devtools to show. Core only records it in + * development. + */ +export interface QueryStartInfo { + /** + * The API that started the query. + */ + verb: 'prefetchHandle' | 'ensureHandle' | 'fetchHandle' | 'waterfallHandle' + /** + * Whether it ran on the server or in the browser. + */ + environment: 'server' | 'client' + /** + * When it ran, in milliseconds since the epoch. + */ + startedAt: number + /** + * Whether an observer, like `useQuery`, has read the query since. + */ + observed: boolean +} + +// START INFO + +// A registered symbol, so devtools that bundle their own copy of this package still find it +const startInfoKey = Symbol.for('@tanstack/query-core/experimental_start') +const watchedCaches = new WeakSet() + +type QueryWithStartInfo = Query & { + [startInfoKey]?: QueryStartInfo +} + +/** + * Stores how a query started on the query itself, hidden from enumeration. + * @param query - The query that started. + * @param info - How it started. + */ +function setStartInfo( + query: Query, + info: QueryStartInfo, +): void { + Object.defineProperty(query, startInfoKey, { + value: info, + configurable: true, + enumerable: false, + writable: true, + }) +} + +/** + * Marks recorded queries as read when an observer subscribes to them. + * @param queryCache - The cache to watch, once. + */ +function watchObservers(queryCache: QueryCache): void { + if (watchedCaches.has(queryCache)) { + return + } + watchedCaches.add(queryCache) + queryCache.subscribe((event) => { + if (event.type === 'observerAdded') { + const info = experimental_getQueryStartInfo(event.query) + if (info && !info.observed) { + setStartInfo(event.query, { ...info, observed: true }) + } + } + }) +} + +/** + * Records how a query started, for the devtools. A query that an observer has read stays read. + * Only call it in development. + * @internal + * @param queryCache - The cache the query belongs to. + * @param query - The query that started. + * @param info - How it started. + */ +export function recordQueryStart( + queryCache: QueryCache, + query: Query, + info: Omit & { observed?: boolean }, +): void { + watchObservers(queryCache) + const observed = + info.observed || + experimental_getQueryStartInfo(query)?.observed || + query.getObserversCount() > 0 + setStartInfo(query, { ...info, observed }) + // A start on fresh data doesn't fetch, so nothing else tells the devtools to read the record + queryCache.notify({ type: 'observerResultsUpdated', query }) +} + +/** + * Returns how a handle API started a query: the last `experimental_prefetchHandle`, + * `experimental_ensureHandle` or `experimental_fetchHandle` call, or the first fetch by a reader of + * an `experimental_waterfallHandle` handle. A query that `hydrate` restores from a server render + * keeps the server's record. Core only records start info in development. + * @param query - The query to look up. + * @returns The start info, or `undefined` in production and for queries that other APIs started. + */ +export function experimental_getQueryStartInfo( + query: Query, +): QueryStartInfo | undefined { + return (query as QueryWithStartInfo)[startInfoKey] +} + +// Kept off the handles, which must stay plain objects that a Server Component can pass on +const waterfallHandles = new WeakSet() + +/** + * Remembers a handle from `experimental_waterfallHandle`, so `experimental_fromHandle` can record + * its reader's first fetch. + * @param handle - The handle to remember. + * @returns The handle. + */ +function markWaterfall(handle: THandle): THandle { + if (process.env.NODE_ENV !== 'production') { + waterfallHandles.add(handle) + } + return handle +} + +/** + * Wraps the `queryFn` of a waterfall handle's options so it records the query's first fetch with + * the verb `waterfallHandle`, since that fetch is the waterfall. + * @param options - Options a query factory returned for the handle's input. + * @returns The options, with the wrapped `queryFn`. + */ +function recordWaterfallStart( + options: QueryFactoryOptions & { queryFn?: unknown }, +): QueryFactoryOptions { + const queryFn = options.queryFn + if (typeof queryFn !== 'function') { + return options + } + return { + ...options, + queryFn: (context: QueryFunctionContext) => { + const queryCache = context.client.getQueryCache() + const query = getQuery(context.client, options) + if (query && !experimental_getQueryStartInfo(query)) { + recordQueryStart(queryCache, query, { + verb: 'waterfallHandle', + environment: environmentManager.isServer() ? 'server' : 'client', + startedAt: Date.now(), + }) + } + return queryFn(context) + }, + } as QueryFactoryOptions +} + +// FUNCTIONS + +/** + * Tells `infiniteQueryOptions` results apart from `queryOptions` results. + * @param options - Options a query factory returned. + * @returns Whether they describe an infinite query. + */ +function isInfiniteOptions(options: QueryFactoryOptions): boolean { + return 'initialPageParam' in options +} + +/** + * Starts a factory's query without waiting. In development, it records the start for the devtools. + * @param client - The client to start the query on. + * @param options - Options a query factory returned. + * @param verb - The API that starts it. + * @returns The promise of the query's data. + */ +function startQueryFromFactory( + client: QueryClient, + options: QueryFactoryOptions, + verb: QueryStartInfo['verb'], +): Promise { + const promise: Promise = isInfiniteOptions(options) + ? client.infiniteQuery(options as never) + : client.query(options) + const query = + process.env.NODE_ENV !== 'production' + ? getQuery(client, options) + : undefined + if (query) { + recordQueryStart(client.getQueryCache(), query, { + verb, + environment: environmentManager.isServer() ? 'server' : 'client', + startedAt: Date.now(), + }) + } + return promise +} + +/** + * Looks up the cache entry of a factory's options. + * @param client - The client whose cache to look in. + * @param options - Options a query factory returned. + * @returns The query, if the cache has it. + */ +function getQuery( + client: QueryClient, + options: QueryFactoryOptions, +): Query | undefined { + return client + .getQueryCache() + .get(client.defaultQueryOptions(options).queryHash) +} + +/** + * Builds a handle. + * @param options - Options a query factory returned. + * @param input - The factory's input. + * @returns A handle with their key and the input. + */ +function toHandle(options: QueryFactoryOptions, input: unknown) { + return { queryKey: options.queryKey, input } +} + +/** + * Starts a query without waiting and returns a serializable handle for it. A route loader, server + * component or server render passes the handle to the component that reads the query. Like + * `queryClient.query`, it fetches only if the cache has no fresh data. It doesn't reject: fetch + * errors show up where a component reads the handle. + * @param queryClient - The client to start the query on. + * @param factory - The query factory, a function of the query's input that returns its options. + * @param input - The input to pass to the factory, if it takes one. + * @returns A handle with the query's key and input. + * @example + * ```ts + * const postQuery = (input: { id: string }) => + * queryOptions({ + * queryKey: ['post', input.id] as const, + * queryFn: () => fetchPost(input.id), + * }) + * + * // in a route loader + * const post = experimental_prefetchHandle(queryClient, postQuery, { id }) + * ``` + */ +export function experimental_prefetchHandle< + TFactory extends QueryHandleFactory, +>( + queryClient: QueryClient, + factory: TFactory, + ...input: QueryHandleArgs +): QueryHandle { + const options = factory(...input) + void startQueryFromFactory(queryClient, options, 'prefetchHandle').catch(noop) + return toHandle(options, input[0]) as QueryHandle +} + +/** + * Starts a query and waits until the cache has data for it, then returns a serializable handle for + * it. It resolves at once with cached data, even stale data, and refetches stale data in the + * background. Rejects if the query has no data and its fetch fails. The handle carries no data; + * read it with `useSuspenseQuery` to get defined data. + * @param queryClient - The client to start the query on. + * @param factory - The query factory, a function of the query's input that returns its options. + * @param input - The input to pass to the factory, if it takes one. + * @returns A promise of a handle with the query's key and input. + * @example + * ```ts + * // in a route loader + * const viewer = await experimental_ensureHandle(queryClient, viewerQuery) + * ``` + */ +export async function experimental_ensureHandle< + TFactory extends QueryHandleFactory, +>( + queryClient: QueryClient, + factory: TFactory, + ...input: QueryHandleArgs +): Promise> { + const options = factory(...input) + const promise = startQueryFromFactory(queryClient, options, 'ensureHandle') + if (getQuery(queryClient, options)?.state.data === undefined) { + await promise + } else { + promise.catch(noop) + } + return toHandle(options, input[0]) as QueryHandle +} + +/** + * Starts a query and waits for fresh data, then returns a serializable handle for it. Like + * `queryClient.query`, it uses cached data only if it isn't stale. Rejects if the fetch fails. The + * handle carries no data; read it with `useSuspenseQuery` to get defined data. + * @param queryClient - The client to start the query on. + * @param factory - The query factory, a function of the query's input that returns its options. + * @param input - The input to pass to the factory, if it takes one. + * @returns A promise of a handle with the query's key and input. + * @example + * ```ts + * // in a route loader + * const post = await experimental_fetchHandle(queryClient, postQuery, { id }) + * ``` + */ +export async function experimental_fetchHandle< + TFactory extends QueryHandleFactory, +>( + queryClient: QueryClient, + factory: TFactory, + ...input: QueryHandleArgs +): Promise> { + const options = factory(...input) + await startQueryFromFactory(queryClient, options, 'fetchHandle') + return toHandle(options, input[0]) as QueryHandle +} + +/** + * Builds the handle of a query whose input you only know during render, like a search term or a + * page a user clicks to. It doesn't start the query: the component that reads the handle does, + * when it renders, which makes it a request waterfall. The name keeps that visible. Prefer + * starting queries in a route loader or server component with `experimental_prefetchHandle`. + * @param factory - The query factory, a function of the query's input that returns its options. + * @param input - The input to pass to the factory, if it takes one. + * @returns A handle with the query's key and input. + * @example + * ```tsx + * function Search(props: { term: string }) { + * const results = experimental_waterfallHandle(searchQuery, { term: props.term }) + * const { data } = useQuery(experimental_fromHandle(searchQuery, results)) + * } + * ``` + */ +export function experimental_waterfallHandle< + TFactory extends QueryHandleFactory, +>(factory: TFactory, ...input: QueryHandleArgs): QueryHandle +/** + * Switches a handle, like one a loader passed down, to a new input, like the next page, and + * returns the handle for that input. It doesn't start the query: the component that reads the + * handle does, when it renders, which makes it a request waterfall. Prefer passing the new input + * to the loader through the URL. + * + * While the new input maps to the handle's own query, it returns the handle as is, so the first + * page reads what the loader started. That also keeps the old input when only fields outside the + * key change, so put everything the `queryFn` reads in the key. + * @param factory - The factory that started the handle's query. + * @param handle - The handle to switch from. + * @param switchInput - Returns the new input, given the handle's input. + * @returns `handle` while the switched input maps to its query, otherwise a handle for the new + * input. + * @example + * ```tsx + * function Posts(props: { posts: experimental_QueryHandle }) { + * const [page, setPage] = React.useState(1) + * const posts = experimental_waterfallHandle(postsQuery, props.posts, (input) => ({ + * ...input, + * page, + * })) + * const { data } = useQuery(experimental_fromHandle(postsQuery, posts)) + * } + * ``` + */ +export function experimental_waterfallHandle< + TFactory extends QueryHandleFactory, +>( + factory: TFactory, + handle: QueryHandle, + switchInput: ( + input: QueryHandleInput, + ) => QueryHandleInput, +): QueryHandle +export function experimental_waterfallHandle< + TFactory extends QueryHandleFactory, +>(factory: TFactory, ...args: Array): QueryHandle { + const [inputOrHandle, switchInput] = args as [ + unknown, + ((input: QueryHandleInput) => QueryHandleInput)?, + ] + if (typeof switchInput !== 'function') { + return markWaterfall( + toHandle(factory(inputOrHandle), inputOrHandle), + ) as QueryHandle + } + const previous = inputOrHandle as QueryHandle + const input = switchInput(previous.input) + const options = factory(input) + if ( + hashQueryKeyByOptions(options.queryKey, options) === + hashQueryKeyByOptions(previous.queryKey as QueryKey, options) + ) { + return previous + } + return markWaterfall(toHandle(options, input)) as QueryHandle +} + +/** + * Rebuilds a query's options from its handle: the options its factory returns for the handle's + * input. Pass them to `useQuery`, `useSuspenseQuery`, `useSuspenseInfiniteQuery` or any other API + * that takes query options, and spread them to add read-site options like `select`. They share the + * cache entry the handle's verb started. They add no `initialData`, so `useQuery` may return + * undefined data, and `useSuspenseQuery` waits for the query's data. + * @param factory - The factory that started the query. + * @param handle - The handle the factory's verb returned. + * @returns The query's options. + * @example + * ```tsx + * function Post(props: { post: QueryHandle }) { + * const { data } = useSuspenseQuery({ + * ...experimental_fromHandle(postQuery, props.post), + * select: (post) => post.title, + * }) + * } + * ``` + */ +export function experimental_fromHandle( + factory: TFactory, + handle: QueryHandle>, +): FromHandleOptions { + const options = + process.env.NODE_ENV !== 'production' && waterfallHandles.has(handle) + ? recordWaterfallStart(factory(handle.input)) + : factory(handle.input) + return options as FromHandleOptions +} diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index 6f763d645ae..1d48f616285 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -1,4 +1,7 @@ +import { environmentManager } from './environmentManager' +import { experimental_getQueryStartInfo, recordQueryStart } from './handles' import { noop } from './utils' +import type { QueryStartInfo } from './handles' import type { DefaultError, MutationKey, @@ -92,6 +95,10 @@ interface DehydratedQuery { promise?: Promise meta?: QueryMeta queryType?: 'infinite' + /** + * In development, how a handle API started the query, for the devtools. + */ + experimental_start?: QueryStartInfo } /** @@ -197,9 +204,20 @@ export function dehydrateQuery( }), ...(query.meta && { meta: query.meta }), ...(query.queryType && { queryType: query.queryType }), + ...(process.env.NODE_ENV !== 'production' && startInfoOf(query)), } } +/** + * Reads how a handle API started a query, for `dehydrateQuery`. + * @param query - The query to dehydrate. + * @returns The `experimental_start` field, if the query has start info. + */ +function startInfoOf(query: Query) { + const start = experimental_getQueryStartInfo(query) + return start && { experimental_start: start } +} + /** * The default `shouldDehydrateMutation` predicate used by `dehydrate`. Only dehydrates mutations that are * currently paused (e.g. paused by `networkMode` while offline). @@ -273,15 +291,50 @@ export function dehydrate( const queries = client .getQueryCache() .getAll() - .flatMap((query) => - filterQuery(query) - ? [dehydrateQuery(query, serializeData, shouldRedactErrors)] - : [], - ) + .flatMap((query) => { + if (filterQuery(query)) { + if (process.env.NODE_ENV !== 'production') { + dehydratedQueries.add(query) + } + return [dehydrateQuery(query, serializeData, shouldRedactErrors)] + } + if (process.env.NODE_ENV !== 'production') { + warnIfPendingHandleQueryIsLeftOut(query) + } + return [] + }) return { mutations, queries } } +// In development, the queries some `dehydrate` call has sent, and those it has warned about +const dehydratedQueries = new WeakSet() +const warnedQueries = new WeakSet() + +/** + * Warns once when a server render leaves out a pending query that a handle verb started, because + * a component that reads the handle in the browser would fetch it again. Queries an earlier + * `dehydrate` call sent don't count, since the browser already gets them. + * @param query - A query that `shouldDehydrateQuery` left out. + */ +function warnIfPendingHandleQueryIsLeftOut(query: Query): void { + const start = experimental_getQueryStartInfo(query) + if ( + start && + start.verb !== 'waterfallHandle' && + start.environment === 'server' && + environmentManager.isServer() && + query.state.status === 'pending' && + !dehydratedQueries.has(query) && + !warnedQueries.has(query) + ) { + warnedQueries.add(query) + console.warn( + `experimental_${start.verb} started the query ${query.queryHash} and it is still pending, but shouldDehydrateQuery left it out, so its data won't reach the client. Include pending queries in shouldDehydrateQuery, or await the query with experimental_ensureHandle or experimental_fetchHandle before you dehydrate.`, + ) + } +} + /** * Restores a `DehydratedState` (as produced by `dehydrate`) into a `QueryClient`'s cache, typically to seed the * client with data already fetched on the server. `mutations` and `queries` are each optional on `dehydratedState`. @@ -335,6 +388,7 @@ export function hydrate( promise, dehydratedAt, queryType, + experimental_start, }) => { const syncData = promise ? tryResolveSync(promise) : undefined const rawData = state.data === undefined ? syncData?.data : state.data @@ -410,6 +464,17 @@ export function hydrate( ) } + if ( + process.env.NODE_ENV !== 'production' && + experimental_start && + !experimental_getQueryStartInfo(query) + ) { + recordQueryStart(queryCache, query, { + ...experimental_start, + observed: false, + }) + } + if ( promise && // If the data was synchronously available, there is no need to set up diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 7d9dd23379f..0d283004d21 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -45,6 +45,21 @@ export { export type { MutationFilters, QueryFilters, SkipToken, Updater } from './utils' export { streamedQuery as experimental_streamedQuery } from './streamedQuery' +export { + experimental_ensureHandle, + experimental_fetchHandle, + experimental_fromHandle, + experimental_getQueryStartInfo, + experimental_prefetchHandle, + experimental_waterfallHandle, +} from './handles' +export type { + FromHandleOptions as experimental_FromHandleOptions, + InferQueryHandleData as experimental_InferQueryHandleData, + QueryHandle as experimental_QueryHandle, + QueryHandleFactory as experimental_QueryHandleFactory, + QueryStartInfo as experimental_QueryStartInfo, +} from './handles' // Types export type { diff --git a/packages/query-devtools/package.json b/packages/query-devtools/package.json index f43bd19b9ad..9ab54764f40 100644 --- a/packages/query-devtools/package.json +++ b/packages/query-devtools/package.json @@ -75,6 +75,7 @@ }, "devDependencies": { "@solidjs/testing-library": "catalog:", + "@tanstack/query-test-utils": "workspace:*", "typescript": "catalog:", "unplugin-solid": "catalog:", "vite-plugin-solid": "catalog:" diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index c13e017377e..1e07a16a2aa 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -25,10 +25,12 @@ import { convertRemToPixels, displayValue, getMutationStatusColor, + getQueryStartInfo, getQueryStatusColor, getQueryStatusColorByLabel, getQueryStatusLabel, getSidedProp, + isPrefetchedButNeverRead, mutationSortFns, sortFns, } from './utils' @@ -1431,6 +1433,15 @@ const QueryRow: Component<{ query: Query }> = (props) => { (e) => e.query.queryHash === props.query.queryHash, ) + const isWaterfall = createSubscribeToQueryCacheBatcher( + (queryCache) => { + const query = queryCache().get(props.query.queryHash) + return !!query && getQueryStartInfo(query)?.verb === 'waterfallHandle' + }, + true, + (e) => e.query.queryHash === props.query.queryHash, + ) + const observers = createSubscribeToQueryCacheBatcher( (queryCache) => queryCache().get(props.query.queryHash)?.getObserversCount() ?? 0, @@ -1479,7 +1490,7 @@ const QueryRow: Component<{ query: Query }> = (props) => { styles().selectedQueryRow, 'tsqd-query-row', )} - aria-label={`Query key ${props.query.queryHash}${isDisabled() ? ', disabled' : ''}${isStatic() ? ', static' : ''}`} + aria-label={`Query key ${props.query.queryHash}${isDisabled() ? ', disabled' : ''}${isStatic() ? ', static' : ''}${isWaterfall() ? ', waterfall' : ''}`} >
= (props) => { static
+ + + ) @@ -1941,6 +1961,20 @@ const QueryDetails = () => { ?.getObserversCount() ?? 0, ) + const startInfo = createSubscribeToQueryCacheBatcher((queryCache) => { + const query = queryCache() + .getAll() + .find((q) => q.queryHash === selectedQueryHash()) + return query && getQueryStartInfo(query) + }) + + const neverRead = createSubscribeToQueryCacheBatcher((queryCache) => { + const query = queryCache() + .getAll() + .find((q) => q.queryHash === selectedQueryHash()) + return !!query && isPrefetchedButNeverRead(query) + }) + const color = createMemo(() => getQueryStatusColorByLabel(statusLabel())) const handleRefetch = () => { @@ -2060,6 +2094,31 @@ const QueryDetails = () => { {new Date(activeQueryState()!.dataUpdatedAt).toLocaleTimeString()} + + {(start) => ( +
+ Started by: + + {start().verb} ({start().environment}) + +
+ )} +
+ +
+ + Prefetched, never read: no component has read this query since{' '} + {startInfo()?.verb} started it. Check whether the loader still + needs to prefetch it. + +
+
{ return { css, glob: () => {}, setup: () => {} } }) +const serverEnvironment = (): Disposable => { + environmentManager.setIsServer(() => true) + return { + [Symbol.dispose]: () => { + environmentManager.setIsServer(() => isServer) + }, + } +} + describe('Devtools', () => { const storage: { [key: string]: string } = {} let queryClient: QueryClient @@ -723,6 +744,105 @@ describe('Devtools', () => { expect(rendered.getByLabelText(/, static/)).toBeInTheDocument() }) + it('should render a "waterfall" indicator for a query that a waterfallHandle reader fetched', () => { + const waterfallQuery = (input: { id: number }) => ({ + queryKey: ['waterfall-q', input.id], + queryFn: () => 'x', + }) + const handle = experimental_waterfallHandle(waterfallQuery, { id: 1 }) + using _subscription = subscribe( + new QueryObserver( + queryClient, + experimental_fromHandle(waterfallQuery, handle), + ), + noop, + ) + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) + + expect(rendered.getByText('waterfall')).toBeInTheDocument() + expect(rendered.getByLabelText(/, waterfall/)).toBeInTheDocument() + }) + + it('should show how a query started and that nothing read it', async () => { + const handle = experimental_prefetchHandle( + queryClient, + (input: { id: number }) => ({ + queryKey: ['prefetched-q', input.id], + queryFn: () => 'x', + }), + { id: 1 }, + ) + await vi.advanceTimersByTimeAsync(0) + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) + + fireEvent.click(rendered.getByLabelText('Query key ["prefetched-q",1]')) + + expect(rendered.getByText('prefetchHandle (client)')).toBeInTheDocument() + expect(rendered.getByRole('note')).toHaveTextContent( + 'Prefetched, never read', + ) + + using _subscription = subscribe( + new QueryObserver(queryClient, { + queryKey: handle.queryKey, + queryFn: () => 'x', + }), + noop, + ) + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.queryByRole('note')).not.toBeInTheDocument() + }) + + it('should show a new start on fresh data, which fetches nothing', async () => { + const factory = () => ({ + queryKey: ['restarted-q'], + queryFn: () => 'x', + staleTime: Infinity, + }) + experimental_prefetchHandle(queryClient, factory) + await vi.advanceTimersByTimeAsync(0) + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) + + fireEvent.click(rendered.getByLabelText('Query key ["restarted-q"]')) + expect(rendered.getByText('prefetchHandle (client)')).toBeInTheDocument() + + await experimental_ensureHandle(queryClient, factory) + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.getByText('ensureHandle (client)')).toBeInTheDocument() + }) + + it('should show where a hydrated query started', async () => { + let dehydrated: DehydratedState + { + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + experimental_prefetchHandle(serverClient, () => ({ + queryKey: ['server-q'], + queryFn: () => 'x', + })) + await vi.advanceTimersByTimeAsync(0) + dehydrated = dehydrate(serverClient) + } + hydrate(queryClient, dehydrated) + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) + + fireEvent.click(rendered.getByLabelText('Query key ["server-q"]')) + + expect(rendered.getByText('prefetchHandle (server)')).toBeInTheDocument() + }) + + it('should not show start info for queries that other APIs start', () => { + queryClient.setQueryData(['plain-q'], 'x') + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) + + fireEvent.click(rendered.getByLabelText('Query key ["plain-q"]')) + + expect(rendered.queryByText('Started by:')).not.toBeInTheDocument() + expect(rendered.queryByText('waterfall')).not.toBeInTheDocument() + }) + it('should render a query row when an object query key is mutated in place', () => { const filters = { page: 1 } queryClient.setQueryData(['mutable-key', filters], 'x') diff --git a/packages/query-devtools/src/utils.tsx b/packages/query-devtools/src/utils.tsx index 23b8df048b9..d5faf1fa4f3 100644 --- a/packages/query-devtools/src/utils.tsx +++ b/packages/query-devtools/src/utils.tsx @@ -1,6 +1,11 @@ import { serialize } from 'superjson' import { createSignal, onCleanup, onMount } from 'solid-js' -import type { Mutation, Query } from '@tanstack/query-core' +import { experimental_getQueryStartInfo } from '@tanstack/query-core' +import type { + Mutation, + Query, + experimental_QueryStartInfo as QueryStartInfo, +} from '@tanstack/query-core' import type { DevtoolsPosition } from './contexts' /** @@ -21,6 +26,32 @@ export function getQueryStatusLabel(query: Query) { : 'fresh' } +/** + * Returns how an experimental handle API started a query, if one did. query-core stores it under a + * registered symbol, so this copy of query-core reads it even when the app uses another copy. + * @param query - The query to look up. + * @returns The start info, or `undefined` for queries that other APIs or older versions started. + */ +export function getQueryStartInfo(query: Query): QueryStartInfo | undefined { + return experimental_getQueryStartInfo(query) +} + +/** + * Whether a handle prefetched a query that no component has read since. That usually means a + * loader prefetches data the page no longer uses. + * @param query - The query to check. + * @returns `true` for a prefetched query that has never had an observer. + */ +export function isPrefetchedButNeverRead(query: Query): boolean { + const start = getQueryStartInfo(query) + return ( + !!start && + start.verb !== 'waterfallHandle' && + !start.observed && + query.getObserversCount() === 0 + ) +} + type QueryStatusLabel = 'fresh' | 'stale' | 'paused' | 'inactive' | 'fetching' /** diff --git a/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts b/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts new file mode 100644 index 00000000000..c50b7ed1d89 --- /dev/null +++ b/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from 'vitest' +import { clearOnDispose } from '../clearOnDispose' + +describe('clearOnDispose', () => { + it('should clear the client on dispose', () => { + const clear = vi.fn<() => void>() + { + using client = clearOnDispose({ clear }) + expect(client.clear).toBe(clear) + expect(clear).not.toHaveBeenCalled() + } + expect(clear).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/query-test-utils/src/__tests__/fakeTimers.test.ts b/packages/query-test-utils/src/__tests__/fakeTimers.test.ts new file mode 100644 index 00000000000..0bbf2f6090b --- /dev/null +++ b/packages/query-test-utils/src/__tests__/fakeTimers.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it, vi } from 'vitest' +import { fakeTimers } from '../fakeTimers' + +describe('fakeTimers', () => { + it('should switch back to real timers on dispose', () => { + { + using _timers = fakeTimers() + expect(vi.isFakeTimers()).toBe(true) + } + expect(vi.isFakeTimers()).toBe(false) + }) +}) diff --git a/packages/query-test-utils/src/__tests__/subscribe.test.ts b/packages/query-test-utils/src/__tests__/subscribe.test.ts new file mode 100644 index 00000000000..ffafc471705 --- /dev/null +++ b/packages/query-test-utils/src/__tests__/subscribe.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest' +import { subscribe } from '../subscribe' + +describe('subscribe', () => { + it('should unsubscribe the listener on dispose', () => { + const listeners = new Set<() => void>() + const subscribable = { + subscribe: (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + } + const listener = vi.fn<() => void>() + { + using _subscription = subscribe(subscribable, listener) + expect(listeners).toStrictEqual(new Set([listener])) + } + expect(listeners.size).toBe(0) + }) +}) diff --git a/packages/query-test-utils/src/__tests__/unmountOnDispose.test.ts b/packages/query-test-utils/src/__tests__/unmountOnDispose.test.ts new file mode 100644 index 00000000000..ea9ebedef41 --- /dev/null +++ b/packages/query-test-utils/src/__tests__/unmountOnDispose.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from 'vitest' +import { unmountOnDispose } from '../unmountOnDispose' + +describe('unmountOnDispose', () => { + it('should unmount on dispose', () => { + const unmount = vi.fn<() => void>() + { + using rendered = unmountOnDispose({ unmount }) + expect(rendered.unmount).toBe(unmount) + expect(unmount).not.toHaveBeenCalled() + } + expect(unmount).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/query-test-utils/src/clearOnDispose.ts b/packages/query-test-utils/src/clearOnDispose.ts new file mode 100644 index 00000000000..9bca266bff4 --- /dev/null +++ b/packages/query-test-utils/src/clearOnDispose.ts @@ -0,0 +1,19 @@ +/** + * Clears a client, like a `QueryClient`, when a test's `using` declaration disposes it. + * @param client - The client to clear. + * @returns The same client, disposable. + * @example + * ```ts + * it('fetches', async () => { + * using queryClient = clearOnDispose(new QueryClient()) + * }) + * ``` + */ +export const clearOnDispose = void }>( + client: TClient, +): TClient & Disposable => + Object.assign(client, { + [Symbol.dispose]: () => { + client.clear() + }, + }) diff --git a/packages/query-test-utils/src/fakeTimers.ts b/packages/query-test-utils/src/fakeTimers.ts new file mode 100644 index 00000000000..5988b14d8b7 --- /dev/null +++ b/packages/query-test-utils/src/fakeTimers.ts @@ -0,0 +1,21 @@ +import { vi } from 'vitest' + +/** + * Switches to fake timers until a test's `using` declaration disposes the result. + * @returns A disposable that switches back to real timers. + * @example + * ```ts + * it('waits', async () => { + * using _timers = fakeTimers() + * await vi.advanceTimersByTimeAsync(10) + * }) + * ``` + */ +export const fakeTimers = (): Disposable => { + vi.useFakeTimers() + return { + [Symbol.dispose]: () => { + vi.useRealTimers() + }, + } +} diff --git a/packages/query-test-utils/src/index.ts b/packages/query-test-utils/src/index.ts index 6366858002e..f704418400a 100644 --- a/packages/query-test-utils/src/index.ts +++ b/packages/query-test-utils/src/index.ts @@ -1,3 +1,7 @@ export { sleep } from './sleep' export { createKey } from './createKey' export { mockVisibilityState } from './mockVisibilityState' +export { fakeTimers } from './fakeTimers' +export { clearOnDispose } from './clearOnDispose' +export { subscribe } from './subscribe' +export { unmountOnDispose } from './unmountOnDispose' diff --git a/packages/query-test-utils/src/subscribe.ts b/packages/query-test-utils/src/subscribe.ts new file mode 100644 index 00000000000..63057dfeebe --- /dev/null +++ b/packages/query-test-utils/src/subscribe.ts @@ -0,0 +1,23 @@ +/** + * Subscribes a listener until a test's `using` declaration disposes the result. + * @param subscribable - What to subscribe to, like a `QueryObserver` or a `QueryCache`. + * @param listener - The listener to subscribe. + * @returns A disposable that unsubscribes the listener. + * @example + * ```ts + * it('fetches', () => { + * using _subscription = subscribe(new QueryObserver(queryClient, options), noop) + * }) + * ``` + */ +export const subscribe = ( + subscribable: { subscribe: (listener: TListener) => () => void }, + listener: NoInfer, +): Disposable => { + const unsubscribe = subscribable.subscribe(listener) + return { + [Symbol.dispose]: () => { + unsubscribe() + }, + } +} diff --git a/packages/query-test-utils/src/unmountOnDispose.ts b/packages/query-test-utils/src/unmountOnDispose.ts new file mode 100644 index 00000000000..f99432faad7 --- /dev/null +++ b/packages/query-test-utils/src/unmountOnDispose.ts @@ -0,0 +1,22 @@ +/** + * Unmounts a render result, like one from Testing Library's `render`, when a test's `using` + * declaration disposes it. Declare it after the client it renders with, so it unmounts before the + * client clears. + * @param rendered - The render result to unmount. + * @returns The same render result, disposable. + * @example + * ```tsx + * it('renders', () => { + * using queryClient = clearOnDispose(new QueryClient()) + * using rendered = unmountOnDispose(renderWithClient(queryClient, )) + * }) + * ``` + */ +export const unmountOnDispose = void }>( + rendered: TRendered, +): TRendered & Disposable => + Object.assign(rendered, { + [Symbol.dispose]: () => { + rendered.unmount() + }, + }) diff --git a/packages/react-query/src/__tests__/queryHandles.test-d.tsx b/packages/react-query/src/__tests__/queryHandles.test-d.tsx new file mode 100644 index 00000000000..9dfdaa85a0d --- /dev/null +++ b/packages/react-query/src/__tests__/queryHandles.test-d.tsx @@ -0,0 +1,656 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { + QueryClient, + experimental_ensureHandle, + experimental_fetchHandle, + experimental_fromHandle, + experimental_prefetchHandle, + experimental_waterfallHandle, + infiniteQueryOptions, + keepPreviousData, + queryOptions, + useInfiniteQuery, + useQuery, + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from '..' +import type { + InfiniteData, + experimental_InferQueryHandleData, + experimental_QueryHandle, +} from '..' + +type Post = { id: string; title: string } + +const postQuery = (input: { id: string }) => + queryOptions({ + queryKey: ['post', input.id] as const, + queryFn: (): Promise => Promise.resolve({ id: input.id, title: '' }), + }) + +const viewerQuery = () => + queryOptions({ + queryKey: ['viewer'] as const, + queryFn: () => Promise.resolve({ name: 'viewer' }), + }) + +const feedQuery = (input: { size: number }) => + infiniteQueryOptions({ + queryKey: ['feed', input.size] as const, + queryFn: ({ pageParam }) => Promise.resolve([pageParam]), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage[0], + }) + +const queryClient = new QueryClient() + +describe('query handles', () => { + describe('verbs', () => { + it("should take the factory's input", () => { + assertType( + experimental_prefetchHandle(queryClient, postQuery, { id: '1' }), + ) + assertType(experimental_prefetchHandle(queryClient, viewerQuery)) + + // @ts-expect-error the input is required + assertType(experimental_prefetchHandle(queryClient, postQuery)) + assertType( + // @ts-expect-error the input has the wrong shape + experimental_prefetchHandle(queryClient, postQuery, { id: 1 }), + ) + assertType( + // @ts-expect-error the factory takes no input + experimental_prefetchHandle(queryClient, viewerQuery, { id: '1' }), + ) + }) + + it("should not take a factory's other parameters, which the handle can't carry", async () => { + const optionsQuery = ( + input: { id: string }, + opts?: { staleTime?: number }, + ) => + queryOptions({ + queryKey: ['post', input.id] as const, + queryFn: () => Promise.resolve(input.id), + staleTime: opts?.staleTime, + }) + + assertType( + experimental_prefetchHandle(queryClient, optionsQuery, { id: '1' }), + ) + assertType( + experimental_prefetchHandle( + queryClient, + optionsQuery, + { id: '1' }, + // @ts-expect-error the handle only carries the input + { staleTime: 60_000 }, + ), + ) + assertType( + await experimental_ensureHandle( + queryClient, + optionsQuery, + { id: '1' }, + // @ts-expect-error the handle only carries the input + { staleTime: 60_000 }, + ), + ) + assertType( + await experimental_fetchHandle( + queryClient, + optionsQuery, + { id: '1' }, + // @ts-expect-error the handle only carries the input + { staleTime: 60_000 }, + ), + ) + const opts = { staleTime: 60_000 } + // @ts-expect-error the handle only carries the input + assertType(experimental_waterfallHandle(optionsQuery, { id: '1' }, opts)) + }) + + it('should take an optional input', () => { + const listQuery = (input?: { page: number }) => + queryOptions({ + queryKey: ['list', input?.page ?? 1] as const, + queryFn: () => Promise.resolve([input?.page ?? 1]), + }) + + assertType(experimental_prefetchHandle(queryClient, listQuery)) + const handle = experimental_prefetchHandle(queryClient, listQuery, { + page: 2, + }) + + expectTypeOf(handle.input).toEqualTypeOf<{ page: number } | undefined>() + }) + + it('should keep optional key elements and drop the data tag', () => { + const pageQuery = (input: { id: string; page?: number }) => + queryOptions({ + queryKey: (input.page === undefined + ? ['page', input.id] + : ['page', input.id, input.page]) as readonly [ + string, + string, + number?, + ], + queryFn: () => Promise.resolve(input.id), + }) + const handle = experimental_prefetchHandle(queryClient, pageQuery, { + id: '1', + }) + + expectTypeOf(handle.queryKey).toEqualTypeOf< + readonly [string, string, (number | undefined)?] + >() + }) + + it('should return a plain handle with the key and input', () => { + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + + expectTypeOf(handle.queryKey).toEqualTypeOf() + expectTypeOf(handle.input).toEqualTypeOf<{ id: string }>() + expectTypeOf(handle).not.toHaveProperty('data') + }) + + it('should give a factory without input an undefined input', () => { + const handle = experimental_prefetchHandle(queryClient, viewerQuery) + + expectTypeOf(handle.input).toEqualTypeOf() + }) + + it('should resolve awaited handles with the same plain handle', async () => { + const ensured = await experimental_ensureHandle(queryClient, postQuery, { + id: '1', + }) + const fetched = await experimental_fetchHandle(queryClient, viewerQuery) + + expectTypeOf(ensured).toEqualTypeOf< + experimental_QueryHandle + >() + expectTypeOf(fetched).toEqualTypeOf< + experimental_QueryHandle + >() + expectTypeOf(ensured).not.toHaveProperty('data') + }) + }) + + describe('experimental_fromHandle', () => { + it('should type useQuery data as possibly undefined for handles from every verb', async () => { + const prefetched = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + const ensured = await experimental_ensureHandle(queryClient, postQuery, { + id: '1', + }) + const fetched = await experimental_fetchHandle(queryClient, postQuery, { + id: '1', + }) + const waterfall = experimental_waterfallHandle(postQuery, { id: '1' }) + + expectTypeOf( + useQuery(experimental_fromHandle(postQuery, prefetched)).data, + ).toEqualTypeOf() + expectTypeOf( + useQuery(experimental_fromHandle(postQuery, ensured)).data, + ).toEqualTypeOf() + expectTypeOf( + useQuery(experimental_fromHandle(postQuery, fetched)).data, + ).toEqualTypeOf() + expectTypeOf( + useQuery(experimental_fromHandle(postQuery, waterfall)).data, + ).toEqualTypeOf() + }) + + it('should type useSuspenseQuery data as defined for handles from every verb', async () => { + const prefetched = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + const ensured = await experimental_ensureHandle(queryClient, postQuery, { + id: '1', + }) + const fetched = await experimental_fetchHandle(queryClient, postQuery, { + id: '1', + }) + const waterfall = experimental_waterfallHandle(postQuery, { id: '1' }) + + expectTypeOf( + useSuspenseQuery(experimental_fromHandle(postQuery, prefetched)).data, + ).toEqualTypeOf() + expectTypeOf( + useSuspenseQuery(experimental_fromHandle(postQuery, ensured)).data, + ).toEqualTypeOf() + expectTypeOf( + useSuspenseQuery(experimental_fromHandle(postQuery, fetched)).data, + ).toEqualTypeOf() + expectTypeOf( + useSuspenseQuery(experimental_fromHandle(postQuery, waterfall)).data, + ).toEqualTypeOf() + }) + + it('should keep useQuery data possibly undefined when read-site options are spread', async () => { + const handle = await experimental_fetchHandle(queryClient, postQuery, { + id: '1', + }) + + const { data } = useQuery({ + ...experimental_fromHandle(postQuery, handle), + select: (post) => post.title, + }) + + expectTypeOf(data).toEqualTypeOf() + }) + + it('should accept keepPreviousData as placeholderData', () => { + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + + const { data } = useQuery({ + ...experimental_fromHandle(postQuery, handle), + placeholderData: keepPreviousData, + }) + + expectTypeOf(data).toEqualTypeOf() + }) + + it('should work with infinite queries', () => { + const handle = experimental_prefetchHandle(queryClient, feedQuery, { + size: 1, + }) + + const { data } = useSuspenseInfiniteQuery( + experimental_fromHandle(feedQuery, handle), + ) + + expectTypeOf(data).toEqualTypeOf, unknown>>() + }) + + it('should type infinite data as possibly undefined for useInfiniteQuery and defined for useSuspenseInfiniteQuery', async () => { + const handle = await experimental_ensureHandle(queryClient, feedQuery, { + size: 1, + }) + + expectTypeOf( + useInfiniteQuery(experimental_fromHandle(feedQuery, handle)).data, + ).toEqualTypeOf, unknown> | undefined>() + expectTypeOf( + useSuspenseInfiniteQuery(experimental_fromHandle(feedQuery, handle)) + .data, + ).toEqualTypeOf, unknown>>() + }) + + it('should take no third argument', () => { + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + + assertType( + // @ts-expect-error read-site options are spread over the result instead + experimental_fromHandle(postQuery, handle, { enabled: false }), + ) + }) + + it("should not accept another factory's handle", () => { + const otherQuery = (input: { id: string }) => + queryOptions({ + queryKey: ['other', input.id] as const, + queryFn: (): Promise => + Promise.resolve({ id: input.id, title: '' }), + }) + const handle = experimental_prefetchHandle(queryClient, otherQuery, { + id: '1', + }) + + // @ts-expect-error the keys differ + assertType(experimental_fromHandle(postQuery, handle)) + }) + + it('should accept handles typed by their factory', () => { + const handle = {} as experimental_QueryHandle + + expectTypeOf( + useQuery(experimental_fromHandle(postQuery, handle)).data, + ).toEqualTypeOf() + }) + }) + + describe('experimental_waterfallHandle', () => { + it("should take the factory's input", () => { + const handle = experimental_waterfallHandle(postQuery, { id: '1' }) + const viewer = experimental_waterfallHandle(viewerQuery) + + expectTypeOf(handle).toEqualTypeOf< + experimental_QueryHandle + >() + expectTypeOf(viewer.input).toEqualTypeOf() + // @ts-expect-error the input has the wrong shape + experimental_waterfallHandle(postQuery, { id: 1 }) + }) + + it('should switch a handle to a new input', () => { + const handle = {} as experimental_QueryHandle + + const next = experimental_waterfallHandle(postQuery, handle, (input) => { + expectTypeOf(input).toEqualTypeOf<{ id: string }>() + return { ...input, id: '2' } + }) + + expectTypeOf(next).toEqualTypeOf< + experimental_QueryHandle + >() + experimental_waterfallHandle( + postQuery, + handle, + // @ts-expect-error the new input has the wrong shape + () => ({ id: 2 }), + ) + }) + }) + + describe('select', () => { + const postTitleQuery = (input: { id: string }) => + queryOptions({ + queryKey: ['post-title', input.id] as const, + queryFn: (): Promise => + Promise.resolve({ id: input.id, title: '' }), + select: (post) => post.title, + }) + + const feedItemsQuery = (input: { size: number }) => + infiniteQueryOptions({ + queryKey: ['feed-items', input.size] as const, + queryFn: ({ pageParam }) => Promise.resolve([pageParam]), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage[0], + select: (data) => data.pages.flat(), + }) + + it('should infer a read-site select from the factory with useQuery', () => { + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + + const { data } = useQuery({ + ...experimental_fromHandle(postQuery, handle), + select: (post) => { + expectTypeOf(post).toEqualTypeOf() + return post.title + }, + placeholderData: (previous) => { + expectTypeOf(previous).toEqualTypeOf() + return previous + }, + enabled: (query) => { + expectTypeOf(query.state.data).toEqualTypeOf() + return true + }, + }) + + expectTypeOf(data).toEqualTypeOf() + }) + + it('should infer a read-site select from the factory with useSuspenseQuery', () => { + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + + const { data } = useSuspenseQuery({ + ...experimental_fromHandle(postQuery, handle), + select: (post) => { + expectTypeOf(post).toEqualTypeOf() + return post.title + }, + }) + + expectTypeOf(data).toEqualTypeOf() + }) + + it('should type read-site placeholderData and enabled from the factory', () => { + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + + assertType( + useQuery({ + ...experimental_fromHandle(postQuery, handle), + placeholderData: { id: '1', title: '' }, + enabled: false, + }), + ) + assertType( + useQuery({ + ...experimental_fromHandle(postQuery, handle), + // @ts-expect-error placeholderData has the query's data type + placeholderData: 'title', + }), + ) + assertType( + useQuery({ + ...experimental_fromHandle(postQuery, handle), + // @ts-expect-error enabled is a boolean or a function of the query + enabled: 'yes', + }), + ) + }) + + it('should infer a read-site select from an infinite factory', () => { + const handle = experimental_prefetchHandle(queryClient, feedQuery, { + size: 1, + }) + + const { data } = useInfiniteQuery({ + ...experimental_fromHandle(feedQuery, handle), + select: (feed) => { + expectTypeOf(feed).toEqualTypeOf< + InfiniteData, number> + >() + return feed.pages.flat() + }, + }) + const suspense = useSuspenseInfiniteQuery({ + ...experimental_fromHandle(feedQuery, handle), + select: (feed) => feed.pages.flat(), + }) + + expectTypeOf(data).toEqualTypeOf | undefined>() + expectTypeOf(suspense.data).toEqualTypeOf>() + }) + + it("should type data from a factory's select", async () => { + const prefetched = experimental_prefetchHandle( + queryClient, + postTitleQuery, + { id: '1' }, + ) + const ensured = await experimental_ensureHandle( + queryClient, + postTitleQuery, + { id: '1' }, + ) + const fetched = await experimental_fetchHandle( + queryClient, + postTitleQuery, + { id: '1' }, + ) + const waterfall = experimental_waterfallHandle(postTitleQuery, { + id: '1', + }) + + expectTypeOf( + useQuery(experimental_fromHandle(postTitleQuery, prefetched)).data, + ).toEqualTypeOf() + expectTypeOf( + useSuspenseQuery(experimental_fromHandle(postTitleQuery, ensured)).data, + ).toEqualTypeOf() + expectTypeOf( + useSuspenseQuery(experimental_fromHandle(postTitleQuery, fetched)).data, + ).toEqualTypeOf() + expectTypeOf( + useSuspenseQuery(experimental_fromHandle(postTitleQuery, waterfall)) + .data, + ).toEqualTypeOf() + }) + + it("should keep a selecting factory's cache data as the queryFn's data", async () => { + const handle = await experimental_ensureHandle( + queryClient, + postTitleQuery, + { id: '1' }, + ) + + expectTypeOf(handle).toEqualTypeOf< + experimental_QueryHandle + >() + expectTypeOf(handle.queryKey).toEqualTypeOf< + readonly ['post-title', string] + >() + expectTypeOf( + queryClient.getQueryData(postTitleQuery({ id: '1' }).queryKey), + ).toEqualTypeOf() + }) + + it("should type data from an infinite factory's select", () => { + const handle = experimental_prefetchHandle(queryClient, feedItemsQuery, { + size: 1, + }) + + expectTypeOf( + useInfiniteQuery(experimental_fromHandle(feedItemsQuery, handle)).data, + ).toEqualTypeOf | undefined>() + expectTypeOf( + useSuspenseInfiniteQuery( + experimental_fromHandle(feedItemsQuery, handle), + ).data, + ).toEqualTypeOf>() + }) + + it("should replace a factory's select with a read-site select over the queryFn's data", () => { + const handle = experimental_prefetchHandle(queryClient, postTitleQuery, { + id: '1', + }) + + const { data } = useSuspenseQuery({ + ...experimental_fromHandle(postTitleQuery, handle), + select: (post) => { + expectTypeOf(post).toEqualTypeOf() + return post.id.length + }, + }) + + expectTypeOf(data).toEqualTypeOf() + }) + }) + + describe('experimental_InferQueryHandleData', () => { + const postTitleQuery = (input: { id: string }) => + queryOptions({ + queryKey: ['post-title', input.id] as const, + queryFn: (): Promise => + Promise.resolve({ id: input.id, title: '' }), + select: (post) => post.title, + }) + + const feedItemsQuery = (input: { size: number }) => + infiniteQueryOptions({ + queryKey: ['feed-items', input.size] as const, + queryFn: ({ pageParam }) => Promise.resolve([pageParam]), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage[0], + select: (data) => data.pages.flat(), + }) + + const handle = {} as experimental_QueryHandle + + it("should give a factory's data, the same as useSuspenseQuery", () => { + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf() + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf<{ name: string }>() + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf( + useSuspenseQuery(experimental_fromHandle(postQuery, handle)).data, + ) + }) + + it("should give what a factory's select returns, the same as useSuspenseQuery", () => { + const titleHandle = {} as experimental_QueryHandle + + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf() + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf( + useSuspenseQuery(experimental_fromHandle(postTitleQuery, titleHandle)) + .data, + ) + }) + + it("should give an infinite factory's data, the same as useSuspenseInfiniteQuery", () => { + const feedHandle = {} as experimental_QueryHandle + + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf, unknown>>() + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf( + useSuspenseInfiniteQuery(experimental_fromHandle(feedQuery, feedHandle)) + .data, + ) + }) + + it("should give what an infinite factory's select returns, the same as useSuspenseInfiniteQuery", () => { + const itemsHandle = {} as experimental_QueryHandle + + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf>() + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf( + useSuspenseInfiniteQuery( + experimental_fromHandle(feedItemsQuery, itemsHandle), + ).data, + ) + }) + + it('should take a handle type', async () => { + const ensured = await experimental_ensureHandle( + queryClient, + postTitleQuery, + { id: '1' }, + ) + + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf() + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf() + }) + + it('should type a prop that takes the data', () => { + function PostTitle(props: { + post: experimental_InferQueryHandleData + }) { + return props.post.title + } + const { data } = useSuspenseQuery( + experimental_fromHandle(postQuery, handle), + ) + const maybe = useQuery(experimental_fromHandle(postQuery, handle)).data + + assertType(PostTitle({ post: data })) + // @ts-expect-error useQuery data may be undefined + assertType(PostTitle({ post: maybe })) + }) + }) +}) diff --git a/packages/react-query/src/__tests__/queryHandles.test.tsx b/packages/react-query/src/__tests__/queryHandles.test.tsx new file mode 100644 index 00000000000..a8f26849b28 --- /dev/null +++ b/packages/react-query/src/__tests__/queryHandles.test.tsx @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest' +import * as React from 'react' +import { act } from '@testing-library/react' +import { + clearOnDispose, + createKey, + fakeTimers, + sleep, + unmountOnDispose, +} from '@tanstack/query-test-utils' +import { + HydrationBoundary, + QueryClient, + defaultShouldDehydrateQuery, + dehydrate, + experimental_fromHandle, + experimental_prefetchHandle, + queryOptions, + useSuspenseQuery, +} from '..' +import { renderWithClient } from './utils' +import type { experimental_QueryHandle } from '..' + +describe('query handles', () => { + it('should stream a pending prefetched query to a reader without fetching it again', async () => { + using _timers = fakeTimers() + const key = createKey() + const queryFn = vi.fn<(id: string) => Promise>((id) => + sleep(10).then(() => `post ${id}`), + ) + const postQuery = (input: { id: string }) => + queryOptions({ + queryKey: [...key, input.id], + queryFn: () => queryFn(input.id), + staleTime: 60 * 1000, + }) + + using serverClient = clearOnDispose(new QueryClient()) + const handle = experimental_prefetchHandle(serverClient, postQuery, { + id: '1', + }) + const dehydrated = dehydrate(serverClient, { + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || query.state.status === 'pending', + }) + expect(dehydrated.queries).toHaveLength(1) + expect(dehydrated.queries[0]?.state.status).toBe('pending') + + function Post(props: { post: experimental_QueryHandle }) { + const { data } = useSuspenseQuery( + experimental_fromHandle(postQuery, props.post), + ) + return
data: {data}
+ } + + using queryClient = clearOnDispose(new QueryClient()) + using rendered = unmountOnDispose( + renderWithClient( + queryClient, + + + + + , + ), + ) + + expect(rendered.getByText('Loading...')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: post 1')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx b/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx new file mode 100644 index 00000000000..def844636b3 --- /dev/null +++ b/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx @@ -0,0 +1,162 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { + QueryClient, + experimental_waterfallHandle, + useQuery, + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from '..' +import { createOptionsProxy } from './trpcLikeProxy' +import type { + InfiniteData, + experimental_InferQueryHandleData, + experimental_QueryHandle, +} from '..' +import type { OptionsProxy } from './trpcLikeProxy' + +type Post = { id: string; title: string } +type Comment = { id: string; text: string } +type FeedPage = { items: Array; nextCursor: number | null } + +const router = { + 'post.byId': (_input: { id: string }): Promise => + Promise.resolve({ id: '1', title: '' }), + 'post.comments': (_input: { + postId: string + page: number + }): Promise> => Promise.resolve([]), + feed: (_input: { + limit: number + cursor?: number | null + }): Promise => Promise.resolve({ items: [], nextCursor: null }), +} + +type TRPC = OptionsProxy + +const trpc = createOptionsProxy(router, new QueryClient()) + +describe('a tRPC-like proxy on the public handle APIs', () => { + it("should type the verbs' input from the procedure", () => { + assertType(trpc['post.byId'].$prefetch({ id: '1' })) + // @ts-expect-error the input has the wrong shape + assertType(trpc['post.byId'].$prefetch({ id: 1 })) + // @ts-expect-error a procedure whose output has no cursor has no infinite verbs + assertType(trpc['post.byId'].$ensureInfinite) + }) + + it('should give awaited handles the same plain type as prefetched ones', async () => { + const post = await trpc['post.byId'].$ensure({ id: '1' }) + const feed = await trpc.feed.$ensureInfinite({ limit: 2 }, { pages: 2 }) + + expectTypeOf(post).toEqualTypeOf(trpc['post.byId'].$prefetch({ id: '1' })) + expectTypeOf(post).not.toHaveProperty('data') + expectTypeOf(post.input).toEqualTypeOf<{ id: string }>() + expectTypeOf(feed.input).toEqualTypeOf< + Omit<{ limit: number; cursor?: number | null }, 'cursor'> + >() + }) + + it('should read any handle without naming its procedure', async () => { + const post = trpc['post.byId'].$prefetch({ id: '1' }) + const ensured = await trpc['post.byId'].$ensure({ id: '1' }) + const feed = await trpc.feed.$ensureInfinite({ limit: 2 }) + + expectTypeOf( + useSuspenseQuery(trpc.$fromHandle(post)).data, + ).toEqualTypeOf() + expectTypeOf(useQuery(trpc.$fromHandle(post)).data).toEqualTypeOf< + Post | undefined + >() + expectTypeOf(useQuery(trpc.$fromHandle(ensured)).data).toEqualTypeOf< + Post | undefined + >() + expectTypeOf( + useSuspenseQuery(trpc.$fromHandle(ensured)).data, + ).toEqualTypeOf() + expectTypeOf( + useSuspenseInfiniteQuery(trpc.$fromHandle(feed)).data, + ).toEqualTypeOf>() + }) + + it('should type select from the procedure when read-site options are spread', () => { + const post = trpc['post.byId'].$prefetch({ id: '1' }) + + const { data } = useSuspenseQuery({ + ...trpc.$fromHandle(post), + select: (value) => { + expectTypeOf(value).toEqualTypeOf() + return value.title + }, + }) + + expectTypeOf(data).toEqualTypeOf() + }) + + it('should type handle props from the procedure', () => { + const props = {} as { + post: experimental_QueryHandle + } + + expectTypeOf(trpc['post.byId'].$prefetch({ id: '1' })).toEqualTypeOf( + props.post, + ) + expectTypeOf( + useSuspenseQuery(trpc.$fromHandle(props.post)).data, + ).toEqualTypeOf() + }) + + it('should not accept handles the router did not start', () => { + const unknownPath = { + queryKey: ['nope', { input: { id: '1' }, type: 'query' }], + input: { id: '1' }, + } as const + const wrongInput = { + queryKey: ['post.byId', { input: { id: 1 }, type: 'query' }], + input: { id: 1 }, + } as const + + // @ts-expect-error the path isn't a procedure of the router + assertType(trpc.$fromHandle(unknownPath)) + // @ts-expect-error the input doesn't match the procedure's + assertType(trpc.$fromHandle(wrongInput)) + }) + + it('should switch a handle with waterfallHandle', () => { + const comments = trpc['post.comments'].$prefetch({ postId: '1', page: 1 }) + + const next = experimental_waterfallHandle( + trpc['post.comments'].queryOptions, + comments, + (input) => { + expectTypeOf(input).toEqualTypeOf<{ postId: string; page: number }>() + return { ...input, page: 2 } + }, + ) + + expectTypeOf(next).toEqualTypeOf(comments) + expectTypeOf(useSuspenseQuery(trpc.$fromHandle(next)).data).toEqualTypeOf< + Array + >() + }) + + it("should infer a procedure's data from its factory, the same as useSuspenseQuery", async () => { + const post = trpc['post.byId'].$prefetch({ id: '1' }) + const feed = await trpc.feed.$ensureInfinite({ limit: 2 }) + + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf() + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf(useSuspenseQuery(trpc.$fromHandle(post)).data) + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf() + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf(useSuspenseInfiniteQuery(trpc.$fromHandle(feed)).data) + expectTypeOf< + experimental_InferQueryHandleData + >().toEqualTypeOf>() + }) +}) diff --git a/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx b/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx new file mode 100644 index 00000000000..cb124e78e87 --- /dev/null +++ b/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest' +import * as React from 'react' +import { act, fireEvent } from '@testing-library/react' +import { + clearOnDispose, + fakeTimers, + sleep, + unmountOnDispose, +} from '@tanstack/query-test-utils' +import { + QueryClient, + dehydrate, + experimental_waterfallHandle, + hydrate, + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from '..' +import { renderWithClient } from './utils' +import { createOptionsProxy } from './trpcLikeProxy' +import type { experimental_QueryHandle } from '..' +import type { OptionsProxy } from './trpcLikeProxy' + +type Post = { id: string; title: string } +type FeedPage = { items: Array; nextCursor: number | null } + +describe('a tRPC-like proxy on the public handle APIs', () => { + const createRouter = () => ({ + 'post.byId': vi.fn<(input: { id: string }) => Promise>((input) => + sleep(10).then(() => ({ id: input.id, title: `post ${input.id}` })), + ), + 'post.comments': vi.fn< + (input: { postId: string; page: number }) => Promise> + >((input) => + sleep(10).then(() => [`comment ${input.postId}-${input.page}`]), + ), + feed: vi.fn< + (input: { limit: number; cursor?: number | null }) => Promise + >((input) => + sleep(10).then(() => { + const cursor = input.cursor ?? 0 + return { + items: [`item ${cursor}`], + nextCursor: cursor + input.limit, + } + }), + ), + }) + + it('should start queries on the server and read the handles on the client', async () => { + using _timers = fakeTimers() + const router = createRouter() + type TRPC = OptionsProxy + + using serverClient = clearOnDispose(new QueryClient()) + const serverTrpc = createOptionsProxy(router, serverClient) + const postPromise = serverTrpc['post.byId'].$ensure({ id: '1' }) + const feedPromise = serverTrpc.feed.$ensureInfinite( + { limit: 10 }, + { pages: 2 }, + ) + await vi.advanceTimersByTimeAsync(10) + const post = await postPromise + const comments = serverTrpc['post.comments'].$prefetch({ + postId: post.input.id, + page: 1, + }) + await vi.advanceTimersByTimeAsync(10) + const feed = await feedPromise + const payload = JSON.parse( + JSON.stringify({ + state: dehydrate(serverClient), + post, + comments, + feed, + }), + ) as { + state: ReturnType + post: typeof post + comments: typeof comments + feed: typeof feed + } + + using queryClient = clearOnDispose(new QueryClient()) + hydrate(queryClient, payload.state) + const trpc = createOptionsProxy(router, queryClient) + + function Post(props: { + post: experimental_QueryHandle + }) { + const { data } = useSuspenseQuery({ + ...trpc.$fromHandle(props.post), + select: (value) => value.title, + }) + return
title: {data}
+ } + + function Comments(props: { + comments: experimental_QueryHandle + }) { + const [page, setPage] = React.useState(1) + const handle = experimental_waterfallHandle( + trpc['post.comments'].queryOptions, + props.comments, + (input) => ({ ...input, page }), + ) + const { data } = useSuspenseQuery(trpc.$fromHandle(handle)) + return ( +
+
comments: {data.join(', ')}
+ +
+ ) + } + + function Feed(props: { feed: typeof feed }) { + const { data } = useSuspenseInfiniteQuery(trpc.$fromHandle(props.feed)) + return ( +
feed: {data.pages.flatMap((page) => page.items).join(', ')}
+ ) + } + + using rendered = unmountOnDispose( + renderWithClient( + queryClient, + + + + + , + ), + ) + + expect(rendered.getByText('title: post 1')).toBeInTheDocument() + expect(rendered.getByText('comments: comment 1-1')).toBeInTheDocument() + expect(rendered.getByText('feed: item 0, item 10')).toBeInTheDocument() + expect(router['post.byId']).toHaveBeenCalledTimes(1) + expect(router['post.comments']).toHaveBeenCalledTimes(1) + expect(router.feed).toHaveBeenCalledTimes(2) + + fireEvent.click(rendered.getByText('next')) + expect(rendered.getByText('Loading...')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('comments: comment 1-2')).toBeInTheDocument() + expect(router['post.comments']).toHaveBeenCalledTimes(2) + expect(router['post.comments']).toHaveBeenLastCalledWith({ + postId: '1', + page: 2, + }) + }) +}) diff --git a/packages/react-query/src/__tests__/trpcLikeProxy.ts b/packages/react-query/src/__tests__/trpcLikeProxy.ts new file mode 100644 index 00000000000..d228e05086b --- /dev/null +++ b/packages/react-query/src/__tests__/trpcLikeProxy.ts @@ -0,0 +1,222 @@ +import { + experimental_ensureHandle, + experimental_fetchHandle, + experimental_fromHandle, + experimental_prefetchHandle, + infiniteQueryOptions, + queryOptions, +} from '..' +import type { + QueryClient, + experimental_FromHandleOptions, + experimental_QueryHandle, + experimental_QueryHandleFactory, +} from '..' + +// A small tRPC-like options proxy that uses only the public handle APIs + +type AnyProcedure = (input: any) => Promise + +export type AnyRouter = Record + +type InputOf = Parameters[0] +type OutputOf = Awaited> + +type Cursor = number | null + +const procedureQueryOptions = ( + path: TPath, + call: (input: TInput) => Promise, + input: TInput, +) => + queryOptions({ + queryKey: [path, { input, type: 'query' }] as const, + queryFn: () => call(input), + }) + +const procedureInfiniteQueryOptions = < + TPath extends string, + TInput extends { cursor?: Cursor }, + TOutput extends { nextCursor: Cursor }, +>( + path: TPath, + call: (input: TInput) => Promise, + input: Omit, +) => + infiniteQueryOptions({ + queryKey: [path, { input, type: 'infinite' }] as const, + queryFn: ({ pageParam }) => call({ ...input, cursor: pageParam } as TInput), + initialPageParam: null as Cursor, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + +type ProcedureQueryOptions< + TPath extends string, + TProcedure extends AnyProcedure, +> = ( + input: InputOf, +) => ReturnType< + typeof procedureQueryOptions, OutputOf> +> + +type ProcedureInfiniteQueryOptions< + TPath extends string, + TInput extends { cursor?: Cursor }, + TOutput extends { nextCursor: Cursor }, +> = ( + input: Omit, +) => ReturnType> + +type DecorateInfiniteProcedure< + TPath extends string, + TProcedure extends AnyProcedure, +> = TProcedure extends ( + input: infer TInput extends { cursor?: Cursor }, +) => Promise + ? { + infiniteQueryOptions: ProcedureInfiniteQueryOptions< + TPath, + TInput, + TOutput + > + $ensureInfinite: ( + input: Omit, + opts?: { pages?: number }, + ) => Promise< + experimental_QueryHandle< + ProcedureInfiniteQueryOptions + > + > + } + : {} + +type DecorateProcedure< + TPath extends string, + TProcedure extends AnyProcedure, +> = { + queryOptions: ProcedureQueryOptions + $prefetch: ( + input: InputOf, + ) => experimental_QueryHandle> + $ensure: ( + input: InputOf, + ) => Promise< + experimental_QueryHandle> + > + $fetch: ( + input: InputOf, + ) => Promise< + experimental_QueryHandle> + > +} & DecorateInfiniteProcedure + +type ProcedureFactory = { + [TPath in keyof TRouter & string]: + | ProcedureQueryOptions + | (DecorateInfiniteProcedure extends { + infiniteQueryOptions: infer TFactory + } + ? TFactory + : never) +}[keyof TRouter & string] + +type HandleOf = TFactory extends experimental_QueryHandleFactory + ? experimental_QueryHandle + : never + +/** + * Any handle a procedure of the router started. + */ +type AnyRouterHandle = HandleOf< + ProcedureFactory +> + +type FactoryOfHandle = THandle extends { + queryKey: readonly [ + infer TPath extends keyof TRouter & string, + { type: infer TType }, + ] +} + ? TType extends 'infinite' + ? DecorateInfiniteProcedure extends { + infiniteQueryOptions: infer TFactory + } + ? TFactory + : never + : ProcedureQueryOptions + : never + +export type OptionsProxy = { + [TPath in keyof TRouter & string]: DecorateProcedure +} & { + /** + * Rebuilds the options of any handle the router's procedures started, from the procedure path + * in its key. + */ + $fromHandle: >( + handle: THandle, + ) => FactoryOfHandle extends infer TFactory extends ( + input: any, + ) => { queryKey: ReadonlyArray } + ? THandle extends experimental_QueryHandle + ? experimental_FromHandleOptions + : never + : never +} + +/** + * Creates an options proxy whose procedures start queries and return handles. + * @param router - The procedures, by path. + * @param queryClient - The client to start queries on. + * @returns The proxy. + */ +export function createOptionsProxy( + router: TRouter, + queryClient: QueryClient, +): OptionsProxy { + const decorate = (path: string) => { + const call = (input: unknown) => router[path]!(input) + const factory = (input: unknown) => procedureQueryOptions(path, call, input) + const infiniteFactory = (input: object) => + procedureInfiniteQueryOptions( + path, + call as (input: { cursor?: Cursor }) => Promise<{ nextCursor: Cursor }>, + input, + ) + return { + queryOptions: factory, + infiniteQueryOptions: infiniteFactory, + $prefetch: (input: unknown) => + experimental_prefetchHandle(queryClient, factory, input), + $ensure: (input: unknown) => + experimental_ensureHandle(queryClient, factory, input), + $fetch: (input: unknown) => + experimental_fetchHandle(queryClient, factory, input), + $ensureInfinite: (input: object, opts?: { pages?: number }) => + experimental_ensureHandle( + queryClient, + (pageInput: object) => ({ + ...infiniteFactory(pageInput), + pages: opts?.pages, + }), + input, + ), + } + } + + const $fromHandle = (handle: { + queryKey: readonly [string, { type: 'query' | 'infinite' }] + input: any + }) => { + const [path, { type }] = handle.queryKey + const procedure = decorate(path) + return type === 'infinite' + ? experimental_fromHandle(procedure.infiniteQueryOptions, handle as never) + : experimental_fromHandle(procedure.queryOptions, handle as never) + } + + return new Proxy({} as OptionsProxy, { + get: (_target, path) => + path === '$fromHandle' ? $fromHandle : decorate(path as string), + }) +} diff --git a/packages/react-query/src/__tests__/waterfallHandle.test.tsx b/packages/react-query/src/__tests__/waterfallHandle.test.tsx new file mode 100644 index 00000000000..296e5be1a23 --- /dev/null +++ b/packages/react-query/src/__tests__/waterfallHandle.test.tsx @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest' +import * as React from 'react' +import { act, fireEvent } from '@testing-library/react' +import { + clearOnDispose, + createKey, + fakeTimers, + sleep, + unmountOnDispose, +} from '@tanstack/query-test-utils' +import { + QueryClient, + experimental_fetchHandle, + experimental_fromHandle, + experimental_getQueryStartInfo, + experimental_prefetchHandle, + experimental_waterfallHandle, + queryOptions, + useQuery, + useSuspenseQuery, +} from '..' +import { renderWithClient } from './utils' +import type { experimental_QueryHandle } from '..' + +describe('experimental_waterfallHandle', () => { + const createPostsQuery = () => { + const key = createKey() + const queryFn = vi.fn<(page: number) => Promise>((page) => + sleep(10).then(() => `page ${page}`), + ) + const postsQuery = (input: { page: number; filter?: string }) => + queryOptions({ + queryKey: [...key, input.page], + queryFn: () => queryFn(input.page), + }) + return { postsQuery, queryFn } + } + + it('should let the reader fetch the query once', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const { postsQuery, queryFn } = createPostsQuery() + + function Posts() { + const posts = experimental_waterfallHandle(postsQuery, { page: 1 }) + const { data } = useSuspenseQuery( + experimental_fromHandle(postsQuery, posts), + ) + return
data: {data}
+ } + + using rendered = unmountOnDispose( + renderWithClient( + queryClient, + + + , + ), + ) + + expect(rendered.getByText('Loading...')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data: page 1')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + const query = queryClient + .getQueryCache() + .find({ queryKey: postsQuery({ page: 1 }).queryKey, exact: true }) + expect(query && experimental_getQueryStartInfo(query)).toStrictEqual( + expect.objectContaining({ verb: 'waterfallHandle', observed: true }), + ) + }) + + it("should keep the loader's handle until the new input maps to another query", async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const { postsQuery, queryFn } = createPostsQuery() + const loaderHandle = experimental_prefetchHandle(queryClient, postsQuery, { + page: 1, + }) + const handles: Array> = [] + + function Posts(props: { + posts: experimental_QueryHandle + }) { + const [page, setPage] = React.useState(1) + const [filter, setFilter] = React.useState('') + const posts = experimental_waterfallHandle( + postsQuery, + props.posts, + (input) => ({ ...input, page, filter }), + ) + handles.push(posts) + const { data } = useQuery(experimental_fromHandle(postsQuery, posts)) + return ( +
+
data: {data ?? 'none'}
+ + +
+ ) + } + + using rendered = unmountOnDispose( + renderWithClient(queryClient, ), + ) + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data: page 1')).toBeInTheDocument() + expect(handles.every((handle) => handle === loaderHandle)).toBe(true) + + fireEvent.click(rendered.getByText('filter')) + expect(handles.at(-1)).toBe(loaderHandle) + expect(queryFn).toHaveBeenCalledTimes(1) + + fireEvent.click(rendered.getByText('next')) + expect(handles.at(-1)).toStrictEqual({ + queryKey: postsQuery({ page: 2 }).queryKey, + input: { page: 2, filter: 'new' }, + }) + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data: page 2')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(2) + }) + + it('should give useQuery the cached data of an awaited handle on the first render', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const { postsQuery } = createPostsQuery() + const promise = experimental_fetchHandle(queryClient, postsQuery, { + page: 1, + }) + await vi.advanceTimersByTimeAsync(10) + const handle = await promise + const statuses: Array = [] + + function Posts() { + const { data, status } = useQuery( + experimental_fromHandle(postsQuery, handle), + ) + statuses.push(status) + return
data: {data}
+ } + + using rendered = unmountOnDispose(renderWithClient(queryClient, )) + + expect(rendered.getByText('data: page 1')).toBeInTheDocument() + expect(statuses[0]).toBe('success') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f355ba6acaf..deeebf86cf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6587,6 +6587,9 @@ importers: '@solidjs/testing-library': specifier: 'catalog:' version: 0.8.10(@solidjs/router@0.15.4(solid-js@1.9.15))(solid-js@1.9.15) + '@tanstack/query-test-utils': + specifier: workspace:* + version: link:../query-test-utils typescript: specifier: 'catalog:' version: 6.0.3