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/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..21d4323200c --- /dev/null +++ b/docs/framework/react/guides/query-handles.md @@ -0,0 +1,288 @@ +--- +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. + +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' + +### 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. 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/handles.ts b/packages/query-core/src/handles.ts new file mode 100644 index 00000000000..5f3f9c5f1c4 --- /dev/null +++ b/packages/query-core/src/handles.ts @@ -0,0 +1,369 @@ +import { hashQueryKeyByOptions, noop } from './utils' +import type { QueryClient } from './queryClient' +import type { Query } from './query' +import type { InferDataFromTag, 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 + +// 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. + * @param client - The client to start the query on. + * @param options - Options a query factory returned. + * @returns The promise of the query's data. + */ +function startQueryFromFactory( + client: QueryClient, + options: QueryFactoryOptions, +): Promise { + return isInfiniteOptions(options) + ? client.infiniteQuery(options as never) + : client.query(options) +} + +/** + * 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).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) + 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) + 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 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 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 { + return factory(handle.input) as FromHandleOptions +} diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 7d9dd23379f..ab80de65190 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -45,6 +45,19 @@ export { export type { MutationFilters, QueryFilters, SkipToken, Updater } from './utils' export { streamedQuery as experimental_streamedQuery } from './streamedQuery' +export { + experimental_ensureHandle, + experimental_fetchHandle, + experimental_fromHandle, + experimental_prefetchHandle, + experimental_waterfallHandle, +} from './handles' +export type { + FromHandleOptions as experimental_FromHandleOptions, + InferQueryHandleData as experimental_InferQueryHandleData, + QueryHandle as experimental_QueryHandle, + QueryHandleFactory as experimental_QueryHandleFactory, +} from './handles' // Types export type { 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..0a349a844a3 --- /dev/null +++ b/packages/react-query/src/__tests__/waterfallHandle.test.tsx @@ -0,0 +1,141 @@ +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_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) + }) + + 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') + }) +})