From 6cd2c9ebdadd135bee8b398e7e63ea33263467ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Nov 2026 23:52:20 +0000 Subject: [PATCH 01/12] test(query-test-utils): add 'fakeTimers' and 'clearOnDispose' disposables for 'using' in tests Co-authored-by: alexander --- .../src/__tests__/disposables.test.ts | 24 +++++++++++++++++++ .../query-test-utils/src/clearOnDispose.ts | 20 ++++++++++++++++ packages/query-test-utils/src/fakeTimers.ts | 21 ++++++++++++++++ packages/query-test-utils/src/index.ts | 2 ++ 4 files changed, 67 insertions(+) create mode 100644 packages/query-test-utils/src/__tests__/disposables.test.ts create mode 100644 packages/query-test-utils/src/clearOnDispose.ts create mode 100644 packages/query-test-utils/src/fakeTimers.ts diff --git a/packages/query-test-utils/src/__tests__/disposables.test.ts b/packages/query-test-utils/src/__tests__/disposables.test.ts new file mode 100644 index 00000000000..4986b32ff39 --- /dev/null +++ b/packages/query-test-utils/src/__tests__/disposables.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import { clearOnDispose, fakeTimers } from '..' + +describe('fakeTimers', () => { + it('should use fake timers until it is disposed', () => { + { + using _timers = fakeTimers() + expect(vi.isFakeTimers()).toBe(true) + } + expect(vi.isFakeTimers()).toBe(false) + }) +}) + +describe('clearOnDispose', () => { + it('should clear the client when it is disposed', () => { + 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/clearOnDispose.ts b/packages/query-test-utils/src/clearOnDispose.ts new file mode 100644 index 00000000000..8e2eb4c3019 --- /dev/null +++ b/packages/query-test-utils/src/clearOnDispose.ts @@ -0,0 +1,20 @@ +/** + * Makes a client, like a `QueryClient`, clear its caches when it is disposed, for a test to hold + * with `using`. + * @param client - The client to clear on dispose. + * @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..c88c18fb89b --- /dev/null +++ b/packages/query-test-utils/src/fakeTimers.ts @@ -0,0 +1,21 @@ +import { vi } from 'vitest' + +/** + * Switches to fake timers until the returned object is disposed, for a test to hold with `using`. + * @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..199560249bb 100644 --- a/packages/query-test-utils/src/index.ts +++ b/packages/query-test-utils/src/index.ts @@ -1,3 +1,5 @@ export { sleep } from './sleep' export { createKey } from './createKey' export { mockVisibilityState } from './mockVisibilityState' +export { fakeTimers } from './fakeTimers' +export { clearOnDispose } from './clearOnDispose' From 56972728a9abba5bfce75d20fda1d6b8b0f515e1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Nov 2026 23:52:20 +0000 Subject: [PATCH 02/12] feat(query-core, react-query): add experimental query handles and 'useWaterfall' Co-authored-by: alexander --- .changeset/experimental-query-handles.md | 6 + .../query-core/src/__tests__/handles.test.tsx | 411 ++++++++++++++++++ packages/query-core/src/handles.ts | 377 ++++++++++++++++ packages/query-core/src/index.ts | 17 + .../src/__tests__/queryHandles.test-d.tsx | 294 +++++++++++++ .../src/__tests__/trpcLikeProxy.test-d.tsx | 134 ++++++ .../src/__tests__/trpcLikeProxy.test.tsx | 143 ++++++ .../src/__tests__/trpcLikeProxy.ts | 223 ++++++++++ .../src/__tests__/useWaterfall.test.tsx | 166 +++++++ packages/react-query/src/index.ts | 1 + packages/react-query/src/useWaterfall.ts | 90 ++++ 11 files changed, 1862 insertions(+) create mode 100644 .changeset/experimental-query-handles.md create mode 100644 packages/query-core/src/__tests__/handles.test.tsx create mode 100644 packages/query-core/src/handles.ts create mode 100644 packages/react-query/src/__tests__/queryHandles.test-d.tsx create mode 100644 packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx create mode 100644 packages/react-query/src/__tests__/trpcLikeProxy.test.tsx create mode 100644 packages/react-query/src/__tests__/trpcLikeProxy.ts create mode 100644 packages/react-query/src/__tests__/useWaterfall.test.tsx create mode 100644 packages/react-query/src/useWaterfall.ts diff --git a/.changeset/experimental-query-handles.md b/.changeset/experimental-query-handles.md new file mode 100644 index 00000000000..491d15535bd --- /dev/null +++ b/.changeset/experimental-query-handles.md @@ -0,0 +1,6 @@ +--- +'@tanstack/query-core': minor +'@tanstack/react-query': minor +--- + +feat: 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 it, and `experimental_useWaterfall` starts a query from a component. 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..d3a6b1dfaa5 --- /dev/null +++ b/packages/query-core/src/__tests__/handles.test.tsx @@ -0,0 +1,411 @@ +import { describe, expect, it, vi } from 'vitest' +import { + clearOnDispose, + createKey, + fakeTimers, + sleep, +} from '@tanstack/query-test-utils' +import { + QueryClient, + QueryObserver, + experimental_ensureHandle, + experimental_fetchHandle, + experimental_fromHandle, + experimental_prefetchHandle, + experimental_waterfallHandle, +} from '..' + +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 carry it', 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' }, + data: '1', + dataUpdatedAt: Date.now(), + }) + }) + + 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, + }) + + const handle = await experimental_ensureHandle(queryClient, feedQuery, { + pages: 3, + }) + + expect(handle.data).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') + + const handle = await experimental_ensureHandle(queryClient, postQuery, { + id: '1', + }) + + expect(handle.data).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(handle.data).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) + + await expect(promise).resolves.toMatchObject({ data: '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') + }) + }) + + 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 pass an awaited handle's data on as initialData", 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.initialData).toBe('1') + expect(options.initialDataUpdatedAt).toBe(handle.dataUpdatedAt) + }) + + it("should give a new client the handle's data", () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = { + queryKey: postQuery({ id: '1' }).queryKey, + input: { id: '1' }, + data: 'from the loader', + dataUpdatedAt: Date.now(), + } + + const observer = new QueryObserver(queryClient, { + ...experimental_fromHandle(postQuery, handle), + enabled: false, + }) + + expect(observer.getCurrentResult()).toMatchObject({ + status: 'success', + data: 'from the loader', + }) + }) + + it('should let data already in the cache win over the handle data', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = { + queryKey: postQuery({ id: '1' }).queryKey, + input: { id: '1' }, + data: 'from the loader', + dataUpdatedAt: Date.now() - 1000, + } + queryClient.setQueryData(handle.queryKey, 'newer') + + const observer = new QueryObserver( + queryClient, + experimental_fromHandle(postQuery, handle), + ) + + expect(observer.getCurrentResult().data).toBe('newer') + }) + }) + + describe('experimental_waterfallHandle', () => { + it('should start a query the cache has never fetched, once', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const queryFn = vi.fn<(id: string) => Promise>((id) => + sleep(10).then(() => id), + ) + const postQuery = createPostQuery(queryFn) + + experimental_waterfallHandle(queryClient, postQuery, { id: '1' }) + experimental_waterfallHandle(queryClient, postQuery, { id: '1' }) + + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should leave stale and errored entries to the reader', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const queryFn = vi.fn<(id: string) => Promise>((id) => + Promise.resolve(id), + ) + const postQuery = createPostQuery(queryFn) + queryClient.setQueryData(postQuery({ id: '1' }).queryKey, 'stale') + const failing = createPostQuery(() => Promise.reject(new Error('no'))) + await queryClient.query(failing({ id: '1' })).catch(() => undefined) + + experimental_waterfallHandle(queryClient, postQuery, { id: '1' }) + experimental_waterfallHandle(queryClient, failing, { id: '1' }) + + expect(queryFn).not.toHaveBeenCalled() + expect( + queryClient.getQueryState(failing({ id: '1' }).queryKey)?.fetchStatus, + ).toBe('idle') + }) + + it('should return the previous handle while the switched 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( + queryClient, + postQuery, + { id: '1', locale: 'en' }, + previous, + ), + ).toBe(previous) + + const next = experimental_waterfallHandle( + queryClient, + postQuery, + { id: '2' }, + previous, + ) + expect(next).toStrictEqual({ + queryKey: [...key, '2'], + input: { id: '2' }, + }) + }) + }) +}) diff --git a/packages/query-core/src/handles.ts b/packages/query-core/src/handles.ts new file mode 100644 index 00000000000..ad9ad5c0326 --- /dev/null +++ b/packages/query-core/src/handles.ts @@ -0,0 +1,377 @@ +import { noop } from './utils' +import type { QueryClient } from './queryClient' +import type { Query } from './query' +import type { + DefaultError, + InferDataFromTag, + InferErrorFromTag, + InfiniteData, + QueryKey, +} from './types' + +// TYPES + +/** + * The options a query factory returns, as built by `queryOptions` or `infiniteQueryOptions`. + */ +interface QueryFactoryOptions { + queryKey: QueryKey +} + +/** + * A function of zero or one argument that returns the options of a query, usually built 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. + */ +export type QueryHandleInput = + Parameters extends [] ? undefined : Parameters[0] + +/** + * The data of a query built by a factory, as tagged on its `queryKey`. For an infinite query + * factory this is the `InfiniteData` of its pages, with the type of its `initialPageParam`. + */ +export type QueryHandleData = + ReturnType extends { initialPageParam: infer TPageParam } + ? InferDataFromTag< + unknown, + ReturnType['queryKey'] + > extends InfiniteData + ? InfiniteData + : InferDataFromTag['queryKey']> + : InferDataFromTag['queryKey']> + +/** + * The error type of a query built by a factory, as tagged on its `queryKey`. + */ +export type QueryHandleError = + InferErrorFromTag['queryKey']> + +type PlainQueryKey< + TQueryKey extends ReadonlyArray, + TItems extends Array = [], +> = number extends TQueryKey['length'] + ? ReadonlyArray + : TItems['length'] extends TQueryKey['length'] + ? Readonly + : PlainQueryKey + +/** + * 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. + */ +export type QueryHandleKey = PlainQueryKey< + ReturnType['queryKey'] +> + +/** + * A small serializable reference to a query started by `experimental_prefetchHandle` or + * `experimental_useWaterfall`. Pass it to the component that reads the query, and rebuild the + * query's options there with `experimental_fromHandle(factory, handle)`. + * + * 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 input the query was started with: the argument passed to its factory. + */ + readonly input: QueryHandleInput +} + +/** + * A handle returned by `experimental_ensureHandle` or `experimental_fetchHandle`, which waited for + * the query's data. It carries that data, so code that called the verb can use it, and + * `experimental_fromHandle` passes it on as `initialData` so readers always have data. + */ +export interface QueryHandleWithData< + TFactory extends QueryHandleFactory = QueryHandleFactory, +> extends QueryHandle { + /** + * The query's data when the verb resolved. Readers get the cache's current data instead. + */ + readonly data: QueryHandleData + /** + * When `data` was last updated, passed on as `initialDataUpdatedAt`. + */ + readonly dataUpdatedAt: number +} + +/** + * The options `experimental_fromHandle` returns: the factory's options for the handle's input and, + * for a handle with data, that data as `initialData`. + */ +export type FromHandleOptions< + TFactory extends QueryHandleFactory, + THandle extends QueryHandle, +> = + THandle extends QueryHandleWithData + ? Omit, 'initialData'> & { + initialData: QueryHandleData + initialDataUpdatedAt: number + } + : ReturnType + +// FUNCTIONS + +/** + * Tells options built by `infiniteQueryOptions` from options built by `queryOptions`. + * @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 query built by a factory, 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 input they were built from. + * @returns A handle with their key and the input. + */ +function toHandle(options: QueryFactoryOptions, input: unknown) { + return { queryKey: options.queryKey, input } +} + +/** + * Builds a handle that carries the query's current data. + * @param client - The client the query is cached on. + * @param options - Options a query factory returned. + * @param input - The input they were built from. + * @returns A handle with their key, the input and the data. + */ +function withData( + client: QueryClient, + options: QueryFactoryOptions, + input: unknown, +) { + const state = getQuery(client, options)?.state + return { + ...toHandle(options, input), + data: state?.data, + dataUpdatedAt: state?.dataUpdatedAt ?? 0, + } +} + +/** + * Starts a query without waiting and returns a serializable handle for it, for a route loader, a + * server component or a server render to pass to the component that reads the query. Like + * `queryClient.query`, it fetches only if the cache has no fresh data, and it never throws: errors + * surface where the handle is read. + * @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: Parameters +): 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 handle that carries the + * data. Cached data resolves at once, even if it is stale, and stale data is refetched in the + * background. Rejects if the query has no data and its fetch fails. + * @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, input and data. + * @example + * ```ts + * const viewer = await experimental_ensureHandle(queryClient, viewerQuery) + * viewer.data.name + * ``` + */ +export async function experimental_ensureHandle< + TFactory extends QueryHandleFactory, +>( + queryClient: QueryClient, + factory: TFactory, + ...input: Parameters +): Promise> { + const options = factory(...input) + const promise = startQueryFromFactory(queryClient, options) + if (getQuery(queryClient, options)?.state.data === undefined) { + await promise + } else { + promise.catch(noop) + } + return withData( + queryClient, + options, + input[0], + ) as QueryHandleWithData +} + +/** + * Starts a query and waits for fresh data, then returns a handle that carries the data. Like + * `queryClient.query`, cached data is used only if it isn't stale. Rejects if the fetch fails. + * @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, input and data. + * @example + * ```ts + * const post = await experimental_fetchHandle(queryClient, postQuery, { id }) + * if (!post.data.published) throw redirect({ to: '/' }) + * ``` + */ +export async function experimental_fetchHandle< + TFactory extends QueryHandleFactory, +>( + queryClient: QueryClient, + factory: TFactory, + ...input: Parameters +): Promise> { + const options = factory(...input) + await startQueryFromFactory(queryClient, options) + return withData( + queryClient, + options, + input[0], + ) as QueryHandleWithData +} + +/** + * Starts a query from an input that is only known during render, and returns its handle. This is a + * request waterfall: the request can't start until a component renders. Framework adapters build + * `experimental_useWaterfall` on it. + * + * It starts the query only if the cache has never fetched it (no entry, or a pending entry that + * isn't fetching), so it is safe to call on every render. Stale data is left to the reader, which + * refetches it on mount like any observer, and an errored entry is left to the reader's error + * handling. + * + * With `previous`, `input` is the switched input for the query `previous` refers to. While it maps + * to the same query, `previous` is returned as is and nothing starts. + * @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. + * @param previous - The handle the input was switched from, if any. + * @returns `previous` while the input maps to its query, otherwise a handle for the new input. + */ +export function experimental_waterfallHandle< + TFactory extends QueryHandleFactory, +>( + queryClient: QueryClient, + factory: TFactory, + input: QueryHandleInput, + previous?: QueryHandle, +): QueryHandle { + const options = factory(input) + if (previous) { + const { queryHash } = queryClient.defaultQueryOptions(options) + const previousHash = queryClient.defaultQueryOptions({ + ...options, + queryKey: previous.queryKey, + queryHash: undefined, + }).queryHash + if (queryHash === previousHash) { + return previous + } + } + const query = getQuery(queryClient, options) + if ( + !query || + (query.state.status === 'pending' && query.state.fetchStatus === 'idle') + ) { + void startQueryFromFactory(queryClient, options).catch(noop) + } + 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. + * + * For a handle from `experimental_ensureHandle` or `experimental_fetchHandle`, the handle's data + * is passed on as `initialData` and `initialDataUpdatedAt`, so readers always have data. Data + * already in the cache wins over it. + * @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< + TFactory extends QueryHandleFactory, + THandle extends QueryHandle, +>(factory: TFactory, handle: THandle): FromHandleOptions { + const options = factory(handle.input) + if ('data' in handle && handle.data !== undefined) { + return { + ...options, + initialData: handle.data, + initialDataUpdatedAt: (handle as unknown as QueryHandleWithData) + .dataUpdatedAt, + } as FromHandleOptions + } + return options as FromHandleOptions +} diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 7d9dd23379f..1cec378e34f 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -45,6 +45,23 @@ 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, + QueryHandle as experimental_QueryHandle, + QueryHandleData as experimental_QueryHandleData, + QueryHandleError as experimental_QueryHandleError, + QueryHandleFactory as experimental_QueryHandleFactory, + QueryHandleInput as experimental_QueryHandleInput, + QueryHandleKey as experimental_QueryHandleKey, + QueryHandleWithData as experimental_QueryHandleWithData, +} from './handles' // Types export type { 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..4cdb8f493fc --- /dev/null +++ b/packages/react-query/src/__tests__/queryHandles.test-d.tsx @@ -0,0 +1,294 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { + QueryClient, + experimental_ensureHandle, + experimental_fetchHandle, + experimental_fromHandle, + experimental_prefetchHandle, + experimental_useWaterfall, + infiniteQueryOptions, + keepPreviousData, + queryOptions, + useInfiniteQuery, + useQuery, + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from '..' +import type { + InfiniteData, + experimental_QueryHandle, + experimental_QueryHandleWithData, +} 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 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 defined data', async () => { + const ensured = await experimental_ensureHandle(queryClient, postQuery, { + id: '1', + }) + const fetched = await experimental_fetchHandle(queryClient, viewerQuery) + + expectTypeOf(ensured.data).toEqualTypeOf() + expectTypeOf(ensured.dataUpdatedAt).toEqualTypeOf() + expectTypeOf(fetched.data).toEqualTypeOf<{ name: string }>() + }) + + it('should type infinite handles with InfiniteData', async () => { + const handle = await experimental_ensureHandle(queryClient, feedQuery, { + size: 1, + }) + + expectTypeOf(handle.data).toEqualTypeOf< + InfiniteData, number> + >() + }) + }) + + describe('experimental_fromHandle', () => { + it('should type useQuery data as possibly undefined for a prefetched handle', () => { + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + + const { data } = useQuery(experimental_fromHandle(postQuery, handle)) + + expectTypeOf(data).toEqualTypeOf() + }) + + it('should type useQuery data as defined for an awaited handle', async () => { + const handle = await experimental_fetchHandle(queryClient, postQuery, { + id: '1', + }) + + const { data } = useQuery(experimental_fromHandle(postQuery, handle)) + + expectTypeOf(data).toEqualTypeOf() + }) + + it('should type useSuspenseQuery data as defined for any handle', () => { + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + + const { data } = useSuspenseQuery( + experimental_fromHandle(postQuery, handle), + ) + + expectTypeOf(data).toEqualTypeOf() + }) + + it('should type select from the factory when read-site options are spread', () => { + 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 keep the data of an awaited handle defined when read-site options are spread', async () => { + const handle = await experimental_ensureHandle(queryClient, postQuery, { + id: '1', + }) + + const { data } = useQuery({ + ...experimental_fromHandle(postQuery, handle), + select: (post) => { + expectTypeOf(post).toEqualTypeOf() + return post.title + }, + refetchInterval: 1000, + }) + + expectTypeOf(data).toEqualTypeOf() + }) + + it('should keep the data of a prefetched handle possibly undefined when read-site options are spread', () => { + const handle = experimental_prefetchHandle(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 pass the pages of an awaited infinite handle on as initialData', async () => { + const handle = await experimental_ensureHandle(queryClient, feedQuery, { + size: 1, + }) + + expectTypeOf( + useInfiniteQuery(experimental_fromHandle(feedQuery, handle)).data, + ).toEqualTypeOf, unknown>>() + 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 + const withData = {} as experimental_QueryHandleWithData + + expectTypeOf( + useQuery(experimental_fromHandle(postQuery, handle)).data, + ).toEqualTypeOf() + expectTypeOf( + useQuery(experimental_fromHandle(postQuery, withData)).data, + ).toEqualTypeOf() + }) + }) + + describe('experimental_useWaterfall', () => { + it("should take the factory's input", () => { + const handle = experimental_useWaterfall(postQuery, { id: '1' }) + const viewer = experimental_useWaterfall(viewerQuery) + + expectTypeOf(handle).toEqualTypeOf< + experimental_QueryHandle + >() + expectTypeOf(viewer.input).toEqualTypeOf() + // @ts-expect-error the input has the wrong shape + experimental_useWaterfall(postQuery, { id: 1 }) + }) + + it('should switch a handle to a new input', () => { + const handle = {} as experimental_QueryHandle + + const next = experimental_useWaterfall(postQuery, handle, (input) => { + expectTypeOf(input).toEqualTypeOf<{ id: string }>() + return { ...input, id: '2' } + }) + + expectTypeOf(next).toEqualTypeOf< + experimental_QueryHandle + >() + experimental_useWaterfall( + postQuery, + handle, + // @ts-expect-error the new input has the wrong shape + () => ({ id: 2 }), + ) + }) + }) +}) 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..d81f1704fac --- /dev/null +++ b/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx @@ -0,0 +1,134 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { + QueryClient, + experimental_useWaterfall, + useQuery, + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from '..' +import { createOptionsProxy } from './trpcLikeProxy' +import type { InfiniteData, 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 defined data', async () => { + const post = await trpc['post.byId'].$ensure({ id: '1' }) + const feed = await trpc.feed.$ensureInfinite({ limit: 2 }, { pages: 2 }) + + expectTypeOf(post.data).toEqualTypeOf() + expectTypeOf(post.input).toEqualTypeOf<{ id: string }>() + expectTypeOf(feed.data).toEqualTypeOf< + InfiniteData + >() + 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() + 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 useWaterfall', () => { + const comments = trpc['post.comments'].$prefetch({ postId: '1', page: 1 }) + + const next = experimental_useWaterfall( + 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 + >() + }) +}) 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..6aff9e088f1 --- /dev/null +++ b/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest' +import * as React from 'react' +import { act, fireEvent } from '@testing-library/react' +import { clearOnDispose, fakeTimers, sleep } from '@tanstack/query-test-utils' +import { + QueryClient, + dehydrate, + experimental_useWaterfall, + 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.data.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_useWaterfall( + 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(', ')}
+ ) + } + + const rendered = 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..4f6911ff574 --- /dev/null +++ b/packages/react-query/src/__tests__/trpcLikeProxy.ts @@ -0,0 +1,223 @@ +import { + experimental_ensureHandle, + experimental_fetchHandle, + experimental_fromHandle, + experimental_prefetchHandle, + infiniteQueryOptions, + queryOptions, +} from '..' +import type { + QueryClient, + experimental_FromHandleOptions, + experimental_QueryHandle, + experimental_QueryHandleFactory, + experimental_QueryHandleWithData, +} from '..' + +// A minimal tRPC-like options proxy, built only on 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, + }) + +export type ProcedureQueryOptions< + TPath extends string, + TProcedure extends AnyProcedure, +> = ( + input: InputOf, +) => ReturnType< + typeof procedureQueryOptions, OutputOf> +> + +export 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_QueryHandleWithData< + ProcedureInfiniteQueryOptions + > + > + } + : {} + +type DecorateProcedure< + TPath extends string, + TProcedure extends AnyProcedure, +> = { + queryOptions: ProcedureQueryOptions + $prefetch: ( + input: InputOf, + ) => experimental_QueryHandle> + $ensure: ( + input: InputOf, + ) => Promise< + experimental_QueryHandleWithData> + > + $fetch: ( + input: InputOf, + ) => Promise< + experimental_QueryHandleWithData> + > +} & 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. + */ +export 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__/useWaterfall.test.tsx b/packages/react-query/src/__tests__/useWaterfall.test.tsx new file mode 100644 index 00000000000..2fc163c24ba --- /dev/null +++ b/packages/react-query/src/__tests__/useWaterfall.test.tsx @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest' +import * as React from 'react' +import { act, fireEvent } from '@testing-library/react' +import { + clearOnDispose, + createKey, + fakeTimers, + sleep, +} from '@tanstack/query-test-utils' +import { + QueryClient, + experimental_fetchHandle, + experimental_fromHandle, + experimental_prefetchHandle, + experimental_useWaterfall, + queryOptions, + useQuery, + useSuspenseQuery, +} from '..' +import { renderWithClient } from './utils' +import type { experimental_QueryHandle } from '..' + +describe('experimental_useWaterfall', () => { + 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 start the query for an input during render, once', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const { postsQuery, queryFn } = createPostsQuery() + const handles: Array> = [] + + function Posts() { + const handle = experimental_useWaterfall(postsQuery, { page: 1 }) + handles.push(handle) + const { data } = useSuspenseQuery( + experimental_fromHandle(postsQuery, handle), + ) + return
data: {data}
+ } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + expect(rendered.getByText('Loading...')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data: page 1')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + expect(handles.at(-1)).toStrictEqual({ + queryKey: postsQuery({ page: 1 }).queryKey, + input: { page: 1 }, + }) + }) + + it('should keep its handle while the input maps to the same query', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const { postsQuery, queryFn } = createPostsQuery() + const handles: Array> = [] + + function Posts(props: { page: number }) { + handles.push(experimental_useWaterfall(postsQuery, { page: props.page })) + return null + } + + const rendered = renderWithClient(queryClient, ) + rendered.rerender() + rendered.rerender() + + expect(handles[0]).toBe(handles[1]) + expect(handles[2]).not.toBe(handles[1]) + expect(handles[2]?.input).toStrictEqual({ page: 2 }) + expect(queryFn).toHaveBeenCalledTimes(2) + }) + + it('should keep the handle a loader passed down until the switched 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_useWaterfall( + postsQuery, + props.posts, + (input) => ({ ...input, page, filter }), + ) + handles.push(posts) + const { data } = useQuery(experimental_fromHandle(postsQuery, posts)) + return ( +
+
data: {data ?? 'none'}
+ + +
+ ) + } + + const rendered = 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) + + 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 an awaited handle's data 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 + using freshClient = clearOnDispose(new QueryClient()) + const statuses: Array = [] + + function Posts() { + const { data, status } = useQuery( + experimental_fromHandle(postsQuery, handle), + ) + statuses.push(status) + return
data: {data}
+ } + + const rendered = renderWithClient(freshClient, ) + + expect(rendered.getByText('data: page 1')).toBeInTheDocument() + expect(statuses[0]).toBe('success') + }) +}) diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts index 6f7197941fa..be36deef51b 100644 --- a/packages/react-query/src/index.ts +++ b/packages/react-query/src/index.ts @@ -17,6 +17,7 @@ export type { } from './useSuspenseQueries' export { usePrefetchQuery } from './usePrefetchQuery' export { usePrefetchInfiniteQuery } from './usePrefetchInfiniteQuery' +export { useWaterfall as experimental_useWaterfall } from './useWaterfall' export { queryOptions } from './queryOptions' export type { DefinedInitialDataOptions, diff --git a/packages/react-query/src/useWaterfall.ts b/packages/react-query/src/useWaterfall.ts new file mode 100644 index 00000000000..59795f7ddb9 --- /dev/null +++ b/packages/react-query/src/useWaterfall.ts @@ -0,0 +1,90 @@ +import * as React from 'react' +import { experimental_waterfallHandle, hashKey } from '@tanstack/query-core' +import { useQueryClient } from './QueryClientProvider' + +import type { + experimental_QueryHandle as QueryHandle, + experimental_QueryHandleFactory as QueryHandleFactory, + experimental_QueryHandleInput as QueryHandleInput, +} from '@tanstack/query-core' + +/** + * Starts a query from an input that is only known during render, and returns its handle. Prefer + * starting queries in a route loader or server component with `experimental_prefetchHandle`: a + * query started here can't begin until this component renders, which makes it a request + * waterfall, and the name keeps that visible. + * + * Like `usePrefetchQuery`, it starts the query only if the cache has never fetched it, so it is + * safe to call on every render, and it runs during server renders too. Read the query with + * `useQuery(experimental_fromHandle(factory, handle))` or `useSuspenseQuery`. + * @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 for the query, stable while the input maps to the same query. + * @example + * ```tsx + * function Search(props: { term: string }) { + * const results = experimental_useWaterfall(searchQuery, { term: props.term }) + * return + * } + * ``` + */ +export function useWaterfall( + factory: TFactory, + ...input: Parameters +): QueryHandle +/** + * Switches a handle a loader passed down to a new input, like the next page, and returns the + * handle of the query for that input. Prefer passing the new input to the loader through the URL: + * a query started here can't begin until this component renders, which makes it a request + * waterfall, and the name keeps that visible. + * + * While the switched input maps to the handle's own query, the handle is returned as is and + * nothing starts. Otherwise the query for the new input starts if the cache has never fetched it. + * @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, stable while the input maps to the same query. + * @example + * ```tsx + * function Posts(props: { posts: experimental_QueryHandle }) { + * const [page, setPage] = React.useState(1) + * const posts = experimental_useWaterfall(postsQuery, props.posts, (input) => ({ + * ...input, + * page, + * })) + * const { data } = useQuery(experimental_fromHandle(postsQuery, posts)) + * } + * ``` + */ +export function useWaterfall( + factory: TFactory, + handle: QueryHandle, + switchInput: ( + input: QueryHandleInput, + ) => QueryHandleInput, +): QueryHandle +export function useWaterfall( + factory: TFactory, + ...args: Array +): QueryHandle { + const [inputOrHandle, switchInput] = args as [ + unknown, + ((input: QueryHandleInput) => QueryHandleInput)?, + ] + const client = useQueryClient() + const previous = + typeof switchInput === 'function' + ? (inputOrHandle as QueryHandle) + : undefined + const input = previous + ? switchInput!(previous.input) + : (inputOrHandle as QueryHandleInput) + + const next = experimental_waterfallHandle(client, factory, input, previous) + const nextHash = next === previous ? '' : hashKey([next.queryKey, next.input]) + // oxlint-disable-next-line react-hooks-js/exhaustive-deps -- 'next' is a new object on every render, so the memo keys on the query and input it refers to + const stable = React.useMemo(() => next, [previous, nextHash]) + + return next === previous ? previous : stable +} From bb8efc5423e107c45e301ca2801c6c7cb606a7c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Nov 2026 23:52:20 +0000 Subject: [PATCH 03/12] docs: add the experimental query handles guide Co-authored-by: alexander --- docs/config.json | 4 + docs/framework/react/guides/query-handles.md | 281 +++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 docs/framework/react/guides/query-handles.md 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..de80e0156c8 --- /dev/null +++ b/docs/framework/react/guides/query-handles.md @@ -0,0 +1,281 @@ +--- +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 and don't start requests of their own. + +A handle is a plain object with the query's key and the input it was started with. It is serializable, so it can cross a loader or Server Component boundary. + +## Query factories + +Handles are built 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 | Handle carries data | +| ----------------------------- | ----------------------------------- | ------------------- | +| `experimental_prefetchHandle` | nothing | no | +| `experimental_ensureHandle` | any data, cached data even if stale | yes | +| `experimental_fetchHandle` | fresh data | yes | + +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` never throws: errors surface where the handle is read. 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 }) { + // The page can't render without the viewer, so wait for it + const viewer = await experimental_ensureHandle(queryClient, viewerQuery) + if (!viewer.data.isAdmin) throw redirect('/') + + return { + viewer, + // 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' + +`select`'s argument is typed from the factory, as with any `queryOptions` result. 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. + +Handles from `experimental_ensureHandle` and `experimental_fetchHandle` carry the data they waited for, and `experimental_fromHandle` passes it on as `initialData` with its `initialDataUpdatedAt`. `useQuery` then always has data, even in a client that never saw the loader's cache, and its `data` is typed as defined. Data already in the cache wins over the handle's data. For a handle from `experimental_prefetchHandle`, `useQuery` data stays possibly `undefined`. + +## Waterfalls: `experimental_useWaterfall` + +Sometimes the input is only known during render, like a search term or a page a user clicks to. `experimental_useWaterfall` starts the query from a component. The request can't start until the component renders, which makes it a [request waterfall](./request-waterfalls.md), and the name keeps that visible. + +Pass it an input: + +[//]: # 'WaterfallInput' + +```tsx +function Search(props: { term: string }) { + const results = experimental_useWaterfall(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_useWaterfall(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, the hook returns that handle and starts nothing, so the first page reads what the loader started. Prefer putting such inputs in the URL so the loader can start them, and use `experimental_useWaterfall` when that isn't practical. + +Like `usePrefetchQuery`, the hook only starts a query the cache has never fetched, so it is safe on every render, and it runs during server renders too. Stale data and errors are left to the component that reads the handle. + +## Server rendering + +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`: + +[//]: # 'DehydratePending' + +```tsx +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' + +A module is shared by every request the server handles. 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. + +A query is sent once, with the state it has when it is first dehydrated. If it changes later in the same request, the change doesn't reach the browser. From c19f1b58a5a22f0635eb372a4035197bae9af8d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 00:01:11 +0000 Subject: [PATCH 04/12] test(react-query): keep the tRPC-like proxy helper types module-private Co-authored-by: alexander --- packages/react-query/src/__tests__/trpcLikeProxy.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-query/src/__tests__/trpcLikeProxy.ts b/packages/react-query/src/__tests__/trpcLikeProxy.ts index 4f6911ff574..c436606969e 100644 --- a/packages/react-query/src/__tests__/trpcLikeProxy.ts +++ b/packages/react-query/src/__tests__/trpcLikeProxy.ts @@ -51,7 +51,7 @@ const procedureInfiniteQueryOptions = < getNextPageParam: (lastPage) => lastPage.nextCursor, }) -export type ProcedureQueryOptions< +type ProcedureQueryOptions< TPath extends string, TProcedure extends AnyProcedure, > = ( @@ -60,7 +60,7 @@ export type ProcedureQueryOptions< typeof procedureQueryOptions, OutputOf> > -export type ProcedureInfiniteQueryOptions< +type ProcedureInfiniteQueryOptions< TPath extends string, TInput extends { cursor?: Cursor }, TOutput extends { nextCursor: Cursor }, @@ -128,7 +128,7 @@ type HandleOf = TFactory extends experimental_QueryHandleFactory /** * Any handle a procedure of the router started. */ -export type AnyRouterHandle = HandleOf< +type AnyRouterHandle = HandleOf< ProcedureFactory > From b0e3c3584e92d4fbf82ebaad0e931ad45c82ee5f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 00:40:16 +0000 Subject: [PATCH 05/12] refactor(query-core, react-query): replace 'experimental_useWaterfall' with a plain 'experimental_waterfallHandle' whose reader starts the query Co-authored-by: alexander --- .changeset/experimental-query-handles.md | 2 +- docs/framework/react/guides/query-handles.md | 24 ++-- .../query-core/src/__tests__/handles.test.tsx | 73 ++++++------ packages/query-core/src/handles.ts | 106 ++++++++++++------ .../src/__tests__/queryHandles.test-d.tsx | 14 +-- .../src/__tests__/trpcLikeProxy.test-d.tsx | 6 +- .../src/__tests__/trpcLikeProxy.test.tsx | 4 +- ...fall.test.tsx => waterfallHandle.test.tsx} | 40 ++----- packages/react-query/src/index.ts | 1 - packages/react-query/src/useWaterfall.ts | 90 --------------- 10 files changed, 141 insertions(+), 219 deletions(-) rename packages/react-query/src/__tests__/{useWaterfall.test.tsx => waterfallHandle.test.tsx} (75%) delete mode 100644 packages/react-query/src/useWaterfall.ts diff --git a/.changeset/experimental-query-handles.md b/.changeset/experimental-query-handles.md index 491d15535bd..b6ac9f39eac 100644 --- a/.changeset/experimental-query-handles.md +++ b/.changeset/experimental-query-handles.md @@ -3,4 +3,4 @@ '@tanstack/react-query': minor --- -feat: 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 it, and `experimental_useWaterfall` starts a query from a component. +feat: 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 it, and `experimental_waterfallHandle` builds a handle for an input only known during render, whose query its reader starts. diff --git a/docs/framework/react/guides/query-handles.md b/docs/framework/react/guides/query-handles.md index de80e0156c8..938b66fd4ea 100644 --- a/docs/framework/react/guides/query-handles.md +++ b/docs/framework/react/guides/query-handles.md @@ -103,9 +103,9 @@ function Post(props: { post: experimental_QueryHandle }) { Handles from `experimental_ensureHandle` and `experimental_fetchHandle` carry the data they waited for, and `experimental_fromHandle` passes it on as `initialData` with its `initialDataUpdatedAt`. `useQuery` then always has data, even in a client that never saw the loader's cache, and its `data` is typed as defined. Data already in the cache wins over the handle's data. For a handle from `experimental_prefetchHandle`, `useQuery` data stays possibly `undefined`. -## Waterfalls: `experimental_useWaterfall` +## Waterfalls: `experimental_waterfallHandle` -Sometimes the input is only known during render, like a search term or a page a user clicks to. `experimental_useWaterfall` starts the query from a component. The request can't start until the component renders, which makes it a [request waterfall](./request-waterfalls.md), and the name keeps that visible. +Sometimes the input is only known 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 it can be called anywhere. Pass it an input: @@ -113,7 +113,9 @@ Pass it an input: ```tsx function Search(props: { term: string }) { - const results = experimental_useWaterfall(searchQuery, { term: props.term }) + const results = experimental_waterfallHandle(searchQuery, { + term: props.term, + }) const { data } = useQuery(experimental_fromHandle(searchQuery, results)) } ``` @@ -127,10 +129,14 @@ Or switch a handle a loader passed down to a new input: ```tsx function Posts(props: { posts: experimental_QueryHandle }) { const [page, setPage] = React.useState(1) - const posts = experimental_useWaterfall(postsQuery, props.posts, (input) => ({ - ...input, - page, - })) + const posts = experimental_waterfallHandle( + postsQuery, + props.posts, + (input) => ({ + ...input, + page, + }), + ) const { data } = useQuery({ ...experimental_fromHandle(postsQuery, posts), placeholderData: keepPreviousData, @@ -140,9 +146,9 @@ function Posts(props: { posts: experimental_QueryHandle }) { [//]: # 'WaterfallSwitch' -While the switched input maps to the handle's own query, the hook returns that handle and starts nothing, so the first page reads what the loader started. Prefer putting such inputs in the URL so the loader can start them, and use `experimental_useWaterfall` when that isn't practical. +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. Prefer putting such inputs in the URL so the loader can start them, and use `experimental_waterfallHandle` when that isn't practical. -Like `usePrefetchQuery`, the hook only starts a query the cache has never fetched, so it is safe on every render, and it runs during server renders too. Stale data and errors are left to the component that reads the handle. +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 diff --git a/packages/query-core/src/__tests__/handles.test.tsx b/packages/query-core/src/__tests__/handles.test.tsx index d3a6b1dfaa5..26bf6a06600 100644 --- a/packages/query-core/src/__tests__/handles.test.tsx +++ b/packages/query-core/src/__tests__/handles.test.tsx @@ -341,7 +341,7 @@ describe('query handles', () => { }) describe('experimental_waterfallHandle', () => { - it('should start a query the cache has never fetched, once', () => { + 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) => @@ -349,33 +349,27 @@ describe('query handles', () => { ) const postQuery = createPostQuery(queryFn) - experimental_waterfallHandle(queryClient, postQuery, { id: '1' }) - experimental_waterfallHandle(queryClient, postQuery, { id: '1' }) + const handle = experimental_waterfallHandle(postQuery, { id: '1' }) - expect(queryFn).toHaveBeenCalledTimes(1) + expect(handle).toStrictEqual({ + queryKey: postQuery({ id: '1' }).queryKey, + input: { id: '1' }, + }) + expect(queryFn).not.toHaveBeenCalled() + expect(queryClient.getQueryCache().getAll()).toHaveLength(0) }) - it('should leave stale and errored entries to the reader', async () => { - using _timers = fakeTimers() - using queryClient = clearOnDispose(new QueryClient()) - const queryFn = vi.fn<(id: string) => Promise>((id) => - Promise.resolve(id), - ) - const postQuery = createPostQuery(queryFn) - queryClient.setQueryData(postQuery({ id: '1' }).queryKey, 'stale') - const failing = createPostQuery(() => Promise.reject(new Error('no'))) - await queryClient.query(failing({ id: '1' })).catch(() => undefined) - - experimental_waterfallHandle(queryClient, postQuery, { id: '1' }) - experimental_waterfallHandle(queryClient, failing, { id: '1' }) + it('should give a factory without input an undefined input', () => { + const key = createKey() + const viewerQuery = () => ({ queryKey: key, queryFn: () => 'viewer' }) - expect(queryFn).not.toHaveBeenCalled() - expect( - queryClient.getQueryState(failing({ id: '1' }).queryKey)?.fetchStatus, - ).toBe('idle') + expect(experimental_waterfallHandle(viewerQuery)).toStrictEqual({ + queryKey: key, + input: undefined, + }) }) - it('should return the previous handle while the switched input maps to its query', () => { + it('should return the handle it switches from while the switched input maps to its query', () => { using _timers = fakeTimers() using queryClient = clearOnDispose(new QueryClient()) const key = createKey() @@ -388,24 +382,31 @@ describe('query handles', () => { }) expect( - experimental_waterfallHandle( - queryClient, - postQuery, - { id: '1', locale: 'en' }, - previous, - ), + experimental_waterfallHandle(postQuery, previous, (input) => ({ + ...input, + locale: 'en', + })), ).toBe(previous) - - const next = experimental_waterfallHandle( - queryClient, - postQuery, - { id: '2' }, - previous, - ) - expect(next).toStrictEqual({ + 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 index ad9ad5c0326..7864709ac1d 100644 --- a/packages/query-core/src/handles.ts +++ b/packages/query-core/src/handles.ts @@ -1,4 +1,4 @@ -import { noop } from './utils' +import { hashQueryKeyByOptions, noop } from './utils' import type { QueryClient } from './queryClient' import type { Query } from './query' import type { @@ -7,6 +7,7 @@ import type { InferErrorFromTag, InfiniteData, QueryKey, + QueryKeyHashFunction, } from './types' // TYPES @@ -16,6 +17,7 @@ import type { */ interface QueryFactoryOptions { queryKey: QueryKey + queryKeyHashFn?: QueryKeyHashFunction } /** @@ -69,8 +71,8 @@ export type QueryHandleKey = PlainQueryKey< > /** - * A small serializable reference to a query started by `experimental_prefetchHandle` or - * `experimental_useWaterfall`. Pass it to the component that reads the query, and rebuild the + * A small serializable reference to a query, from `experimental_prefetchHandle` or + * `experimental_waterfallHandle`. Pass it to the component that reads the query, and rebuild the * query's options there with `experimental_fromHandle(factory, handle)`. * * Handles of two factories only mix when their keys have the same type. Declare keys with @@ -291,49 +293,79 @@ export async function experimental_fetchHandle< } /** - * Starts a query from an input that is only known during render, and returns its handle. This is a - * request waterfall: the request can't start until a component renders. Framework adapters build - * `experimental_useWaterfall` on it. - * - * It starts the query only if the cache has never fetched it (no entry, or a pending entry that - * isn't fetching), so it is safe to call on every render. Stale data is left to the reader, which - * refetches it on mount like any observer, and an errored entry is left to the reader's error - * handling. - * - * With `previous`, `input` is the switched input for the query `previous` refers to. While it maps - * to the same query, `previous` is returned as is and nothing starts. - * @param queryClient - The client to start the query on. + * Builds the handle of a query whose input is only known 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. - * @param previous - The handle the input was switched from, if any. - * @returns `previous` while the input maps to its query, otherwise a handle for the new input. + * @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: Parameters): 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 switched input maps to the handle's own query, the handle is returned as is, so the + * first page reads what the loader started. + * @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, >( - queryClient: QueryClient, factory: TFactory, - input: QueryHandleInput, - previous?: QueryHandle, -): QueryHandle { - const options = factory(input) - if (previous) { - const { queryHash } = queryClient.defaultQueryOptions(options) - const previousHash = queryClient.defaultQueryOptions({ - ...options, - queryKey: previous.queryKey, - queryHash: undefined, - }).queryHash - if (queryHash === previousHash) { - return previous - } + 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 query = getQuery(queryClient, options) + const previous = inputOrHandle as QueryHandle + const input = switchInput(previous.input) + const options = factory(input) if ( - !query || - (query.state.status === 'pending' && query.state.fetchStatus === 'idle') + hashQueryKeyByOptions(options.queryKey, options) === + hashQueryKeyByOptions(previous.queryKey as QueryKey, options) ) { - void startQueryFromFactory(queryClient, options).catch(noop) + return previous } return toHandle(options, input) as QueryHandle } diff --git a/packages/react-query/src/__tests__/queryHandles.test-d.tsx b/packages/react-query/src/__tests__/queryHandles.test-d.tsx index 4cdb8f493fc..0e81afd6d28 100644 --- a/packages/react-query/src/__tests__/queryHandles.test-d.tsx +++ b/packages/react-query/src/__tests__/queryHandles.test-d.tsx @@ -5,7 +5,7 @@ import { experimental_fetchHandle, experimental_fromHandle, experimental_prefetchHandle, - experimental_useWaterfall, + experimental_waterfallHandle, infiniteQueryOptions, keepPreviousData, queryOptions, @@ -259,23 +259,23 @@ describe('query handles', () => { }) }) - describe('experimental_useWaterfall', () => { + describe('experimental_waterfallHandle', () => { it("should take the factory's input", () => { - const handle = experimental_useWaterfall(postQuery, { id: '1' }) - const viewer = experimental_useWaterfall(viewerQuery) + 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_useWaterfall(postQuery, { id: 1 }) + experimental_waterfallHandle(postQuery, { id: 1 }) }) it('should switch a handle to a new input', () => { const handle = {} as experimental_QueryHandle - const next = experimental_useWaterfall(postQuery, handle, (input) => { + const next = experimental_waterfallHandle(postQuery, handle, (input) => { expectTypeOf(input).toEqualTypeOf<{ id: string }>() return { ...input, id: '2' } }) @@ -283,7 +283,7 @@ describe('query handles', () => { expectTypeOf(next).toEqualTypeOf< experimental_QueryHandle >() - experimental_useWaterfall( + experimental_waterfallHandle( postQuery, handle, // @ts-expect-error the new input has the wrong shape diff --git a/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx b/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx index d81f1704fac..cceddd74b8b 100644 --- a/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx +++ b/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx @@ -1,7 +1,7 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' import { QueryClient, - experimental_useWaterfall, + experimental_waterfallHandle, useQuery, useSuspenseInfiniteQuery, useSuspenseQuery, @@ -114,10 +114,10 @@ describe('a tRPC-like proxy on the public handle APIs', () => { assertType(trpc.$fromHandle(wrongInput)) }) - it('should switch a handle with useWaterfall', () => { + it('should switch a handle with waterfallHandle', () => { const comments = trpc['post.comments'].$prefetch({ postId: '1', page: 1 }) - const next = experimental_useWaterfall( + const next = experimental_waterfallHandle( trpc['post.comments'].queryOptions, comments, (input) => { diff --git a/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx b/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx index 6aff9e088f1..ae8cc2fd2b0 100644 --- a/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx +++ b/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx @@ -5,7 +5,7 @@ import { clearOnDispose, fakeTimers, sleep } from '@tanstack/query-test-utils' import { QueryClient, dehydrate, - experimental_useWaterfall, + experimental_waterfallHandle, hydrate, useSuspenseInfiniteQuery, useSuspenseQuery, @@ -93,7 +93,7 @@ describe('a tRPC-like proxy on the public handle APIs', () => { comments: experimental_QueryHandle }) { const [page, setPage] = React.useState(1) - const handle = experimental_useWaterfall( + const handle = experimental_waterfallHandle( trpc['post.comments'].queryOptions, props.comments, (input) => ({ ...input, page }), diff --git a/packages/react-query/src/__tests__/useWaterfall.test.tsx b/packages/react-query/src/__tests__/waterfallHandle.test.tsx similarity index 75% rename from packages/react-query/src/__tests__/useWaterfall.test.tsx rename to packages/react-query/src/__tests__/waterfallHandle.test.tsx index 2fc163c24ba..2f40f48c058 100644 --- a/packages/react-query/src/__tests__/useWaterfall.test.tsx +++ b/packages/react-query/src/__tests__/waterfallHandle.test.tsx @@ -12,7 +12,7 @@ import { experimental_fetchHandle, experimental_fromHandle, experimental_prefetchHandle, - experimental_useWaterfall, + experimental_waterfallHandle, queryOptions, useQuery, useSuspenseQuery, @@ -20,7 +20,7 @@ import { import { renderWithClient } from './utils' import type { experimental_QueryHandle } from '..' -describe('experimental_useWaterfall', () => { +describe('experimental_waterfallHandle', () => { const createPostsQuery = () => { const key = createKey() const queryFn = vi.fn<(page: number) => Promise>((page) => @@ -34,17 +34,15 @@ describe('experimental_useWaterfall', () => { return { postsQuery, queryFn } } - it('should start the query for an input during render, once', async () => { + it('should let the reader fetch the query for an input, once', async () => { using _timers = fakeTimers() using queryClient = clearOnDispose(new QueryClient()) const { postsQuery, queryFn } = createPostsQuery() - const handles: Array> = [] function Posts() { - const handle = experimental_useWaterfall(postsQuery, { page: 1 }) - handles.push(handle) + const posts = experimental_waterfallHandle(postsQuery, { page: 1 }) const { data } = useSuspenseQuery( - experimental_fromHandle(postsQuery, handle), + experimental_fromHandle(postsQuery, posts), ) return
data: {data}
} @@ -60,31 +58,6 @@ describe('experimental_useWaterfall', () => { await act(() => vi.advanceTimersByTimeAsync(11)) expect(rendered.getByText('data: page 1')).toBeInTheDocument() expect(queryFn).toHaveBeenCalledTimes(1) - expect(handles.at(-1)).toStrictEqual({ - queryKey: postsQuery({ page: 1 }).queryKey, - input: { page: 1 }, - }) - }) - - it('should keep its handle while the input maps to the same query', () => { - using _timers = fakeTimers() - using queryClient = clearOnDispose(new QueryClient()) - const { postsQuery, queryFn } = createPostsQuery() - const handles: Array> = [] - - function Posts(props: { page: number }) { - handles.push(experimental_useWaterfall(postsQuery, { page: props.page })) - return null - } - - const rendered = renderWithClient(queryClient, ) - rendered.rerender() - rendered.rerender() - - expect(handles[0]).toBe(handles[1]) - expect(handles[2]).not.toBe(handles[1]) - expect(handles[2]?.input).toStrictEqual({ page: 2 }) - expect(queryFn).toHaveBeenCalledTimes(2) }) it('should keep the handle a loader passed down until the switched input maps to another query', async () => { @@ -101,7 +74,7 @@ describe('experimental_useWaterfall', () => { }) { const [page, setPage] = React.useState(1) const [filter, setFilter] = React.useState('') - const posts = experimental_useWaterfall( + const posts = experimental_waterfallHandle( postsQuery, props.posts, (input) => ({ ...input, page, filter }), @@ -127,6 +100,7 @@ describe('experimental_useWaterfall', () => { 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({ diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts index be36deef51b..6f7197941fa 100644 --- a/packages/react-query/src/index.ts +++ b/packages/react-query/src/index.ts @@ -17,7 +17,6 @@ export type { } from './useSuspenseQueries' export { usePrefetchQuery } from './usePrefetchQuery' export { usePrefetchInfiniteQuery } from './usePrefetchInfiniteQuery' -export { useWaterfall as experimental_useWaterfall } from './useWaterfall' export { queryOptions } from './queryOptions' export type { DefinedInitialDataOptions, diff --git a/packages/react-query/src/useWaterfall.ts b/packages/react-query/src/useWaterfall.ts deleted file mode 100644 index 59795f7ddb9..00000000000 --- a/packages/react-query/src/useWaterfall.ts +++ /dev/null @@ -1,90 +0,0 @@ -import * as React from 'react' -import { experimental_waterfallHandle, hashKey } from '@tanstack/query-core' -import { useQueryClient } from './QueryClientProvider' - -import type { - experimental_QueryHandle as QueryHandle, - experimental_QueryHandleFactory as QueryHandleFactory, - experimental_QueryHandleInput as QueryHandleInput, -} from '@tanstack/query-core' - -/** - * Starts a query from an input that is only known during render, and returns its handle. Prefer - * starting queries in a route loader or server component with `experimental_prefetchHandle`: a - * query started here can't begin until this component renders, which makes it a request - * waterfall, and the name keeps that visible. - * - * Like `usePrefetchQuery`, it starts the query only if the cache has never fetched it, so it is - * safe to call on every render, and it runs during server renders too. Read the query with - * `useQuery(experimental_fromHandle(factory, handle))` or `useSuspenseQuery`. - * @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 for the query, stable while the input maps to the same query. - * @example - * ```tsx - * function Search(props: { term: string }) { - * const results = experimental_useWaterfall(searchQuery, { term: props.term }) - * return - * } - * ``` - */ -export function useWaterfall( - factory: TFactory, - ...input: Parameters -): QueryHandle -/** - * Switches a handle a loader passed down to a new input, like the next page, and returns the - * handle of the query for that input. Prefer passing the new input to the loader through the URL: - * a query started here can't begin until this component renders, which makes it a request - * waterfall, and the name keeps that visible. - * - * While the switched input maps to the handle's own query, the handle is returned as is and - * nothing starts. Otherwise the query for the new input starts if the cache has never fetched it. - * @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, stable while the input maps to the same query. - * @example - * ```tsx - * function Posts(props: { posts: experimental_QueryHandle }) { - * const [page, setPage] = React.useState(1) - * const posts = experimental_useWaterfall(postsQuery, props.posts, (input) => ({ - * ...input, - * page, - * })) - * const { data } = useQuery(experimental_fromHandle(postsQuery, posts)) - * } - * ``` - */ -export function useWaterfall( - factory: TFactory, - handle: QueryHandle, - switchInput: ( - input: QueryHandleInput, - ) => QueryHandleInput, -): QueryHandle -export function useWaterfall( - factory: TFactory, - ...args: Array -): QueryHandle { - const [inputOrHandle, switchInput] = args as [ - unknown, - ((input: QueryHandleInput) => QueryHandleInput)?, - ] - const client = useQueryClient() - const previous = - typeof switchInput === 'function' - ? (inputOrHandle as QueryHandle) - : undefined - const input = previous - ? switchInput!(previous.input) - : (inputOrHandle as QueryHandleInput) - - const next = experimental_waterfallHandle(client, factory, input, previous) - const nextHash = next === previous ? '' : hashKey([next.queryKey, next.input]) - // oxlint-disable-next-line react-hooks-js/exhaustive-deps -- 'next' is a new object on every render, so the memo keys on the query and input it refers to - const stable = React.useMemo(() => next, [previous, nextHash]) - - return next === previous ? previous : stable -} From 53e3fb6b052e7f5f03cbf5ccf729bb444f0d8fda Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 00:57:56 +0000 Subject: [PATCH 06/12] test(react-query): unmount the handle tests' renders with 'using' Adds an 'unmountOnDispose' helper to @tanstack/query-test-utils, so a render declared after its client unmounts before the client is cleared, and splits the helper tests into one file per helper. Co-authored-by: alexander --- ...osables.test.ts => clearOnDispose.test.ts} | 12 +--------- .../src/__tests__/fakeTimers.test.ts | 12 ++++++++++ .../src/__tests__/unmountOnDispose.test.ts | 14 +++++++++++ packages/query-test-utils/src/index.ts | 1 + .../query-test-utils/src/unmountOnDispose.ts | 22 ++++++++++++++++++ .../src/__tests__/trpcLikeProxy.test.tsx | 23 ++++++++++++------- .../src/__tests__/waterfallHandle.test.tsx | 20 ++++++++-------- 7 files changed, 76 insertions(+), 28 deletions(-) rename packages/query-test-utils/src/__tests__/{disposables.test.ts => clearOnDispose.test.ts} (57%) create mode 100644 packages/query-test-utils/src/__tests__/fakeTimers.test.ts create mode 100644 packages/query-test-utils/src/__tests__/unmountOnDispose.test.ts create mode 100644 packages/query-test-utils/src/unmountOnDispose.ts diff --git a/packages/query-test-utils/src/__tests__/disposables.test.ts b/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts similarity index 57% rename from packages/query-test-utils/src/__tests__/disposables.test.ts rename to packages/query-test-utils/src/__tests__/clearOnDispose.test.ts index 4986b32ff39..beeb1557670 100644 --- a/packages/query-test-utils/src/__tests__/disposables.test.ts +++ b/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts @@ -1,15 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { clearOnDispose, fakeTimers } from '..' - -describe('fakeTimers', () => { - it('should use fake timers until it is disposed', () => { - { - using _timers = fakeTimers() - expect(vi.isFakeTimers()).toBe(true) - } - expect(vi.isFakeTimers()).toBe(false) - }) -}) +import { clearOnDispose } from '../clearOnDispose' describe('clearOnDispose', () => { it('should clear the client when it is disposed', () => { 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..75e1cdbab80 --- /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 use fake timers until it is disposed', () => { + { + using _timers = fakeTimers() + expect(vi.isFakeTimers()).toBe(true) + } + expect(vi.isFakeTimers()).toBe(false) + }) +}) 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..9ced68c140b --- /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 the rendered tree when it is disposed', () => { + 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/index.ts b/packages/query-test-utils/src/index.ts index 199560249bb..f837bd7e936 100644 --- a/packages/query-test-utils/src/index.ts +++ b/packages/query-test-utils/src/index.ts @@ -3,3 +3,4 @@ export { createKey } from './createKey' export { mockVisibilityState } from './mockVisibilityState' export { fakeTimers } from './fakeTimers' export { clearOnDispose } from './clearOnDispose' +export { unmountOnDispose } from './unmountOnDispose' diff --git a/packages/query-test-utils/src/unmountOnDispose.ts b/packages/query-test-utils/src/unmountOnDispose.ts new file mode 100644 index 00000000000..325a9b71c1d --- /dev/null +++ b/packages/query-test-utils/src/unmountOnDispose.ts @@ -0,0 +1,22 @@ +/** + * Makes a rendered tree, like the result of Testing Library's `render`, unmount when it is + * disposed, for a test to hold with `using`. Declared after the client it renders with, it + * unmounts before that client is cleared. + * @param rendered - The render result to unmount on dispose. + * @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__/trpcLikeProxy.test.tsx b/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx index ae8cc2fd2b0..b94cdc7aa52 100644 --- a/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx +++ b/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx @@ -1,7 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import * as React from 'react' import { act, fireEvent } from '@testing-library/react' -import { clearOnDispose, fakeTimers, sleep } from '@tanstack/query-test-utils' +import { + clearOnDispose, + fakeTimers, + sleep, + unmountOnDispose, +} from '@tanstack/query-test-utils' import { QueryClient, dehydrate, @@ -114,13 +119,15 @@ describe('a tRPC-like proxy on the public handle APIs', () => { ) } - const rendered = renderWithClient( - queryClient, - - - - - , + using rendered = unmountOnDispose( + renderWithClient( + queryClient, + + + + + , + ), ) expect(rendered.getByText('title: post 1')).toBeInTheDocument() diff --git a/packages/react-query/src/__tests__/waterfallHandle.test.tsx b/packages/react-query/src/__tests__/waterfallHandle.test.tsx index 2f40f48c058..3b0e67cd2a2 100644 --- a/packages/react-query/src/__tests__/waterfallHandle.test.tsx +++ b/packages/react-query/src/__tests__/waterfallHandle.test.tsx @@ -6,6 +6,7 @@ import { createKey, fakeTimers, sleep, + unmountOnDispose, } from '@tanstack/query-test-utils' import { QueryClient, @@ -47,11 +48,13 @@ describe('experimental_waterfallHandle', () => { return
data: {data}
} - const rendered = renderWithClient( - queryClient, - - - , + using rendered = unmountOnDispose( + renderWithClient( + queryClient, + + + , + ), ) expect(rendered.getByText('Loading...')).toBeInTheDocument() @@ -90,9 +93,8 @@ describe('experimental_waterfallHandle', () => { ) } - const rendered = renderWithClient( - queryClient, - , + using rendered = unmountOnDispose( + renderWithClient(queryClient, ), ) await act(() => vi.advanceTimersByTimeAsync(11)) expect(rendered.getByText('data: page 1')).toBeInTheDocument() @@ -132,7 +134,7 @@ describe('experimental_waterfallHandle', () => { return
data: {data}
} - const rendered = renderWithClient(freshClient, ) + using rendered = unmountOnDispose(renderWithClient(freshClient, )) expect(rendered.getByText('data: page 1')).toBeInTheDocument() expect(statuses[0]).toBe('success') From 8b31d3b120706596c3c42ca6f30c414aba762500 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 14:06:57 +0000 Subject: [PATCH 07/12] docs(query-core, react-query): use plainer words in the query handles copy Co-authored-by: alexander --- .changeset/experimental-query-handles.md | 2 +- docs/framework/react/guides/query-handles.md | 16 +++---- .../query-core/src/__tests__/handles.test.tsx | 4 +- packages/query-core/src/handles.ts | 48 +++++++++---------- .../src/__tests__/clearOnDispose.test.ts | 2 +- .../src/__tests__/fakeTimers.test.ts | 2 +- .../src/__tests__/unmountOnDispose.test.ts | 2 +- .../query-test-utils/src/clearOnDispose.ts | 5 +- packages/query-test-utils/src/fakeTimers.ts | 2 +- .../query-test-utils/src/unmountOnDispose.ts | 8 ++-- .../src/__tests__/trpcLikeProxy.ts | 2 +- .../src/__tests__/waterfallHandle.test.tsx | 4 +- 12 files changed, 48 insertions(+), 49 deletions(-) diff --git a/.changeset/experimental-query-handles.md b/.changeset/experimental-query-handles.md index b6ac9f39eac..a360093eed8 100644 --- a/.changeset/experimental-query-handles.md +++ b/.changeset/experimental-query-handles.md @@ -3,4 +3,4 @@ '@tanstack/react-query': minor --- -feat: 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 it, and `experimental_waterfallHandle` builds a handle for an input only known during render, whose query its reader starts. +feat: 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. diff --git a/docs/framework/react/guides/query-handles.md b/docs/framework/react/guides/query-handles.md index 938b66fd4ea..2e34ae7cc7d 100644 --- a/docs/framework/react/guides/query-handles.md +++ b/docs/framework/react/guides/query-handles.md @@ -7,11 +7,11 @@ title: Query Handles (experimental) 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 and don't start requests of their own. -A handle is a plain object with the query's key and the input it was started with. It is serializable, so it can cross a loader or Server Component boundary. +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 are built from query factories: functions of one input (or none) that return query options. +Handles come from query factories: functions of one input (or none) that return query options. [//]: # 'Factories' @@ -48,7 +48,7 @@ Three functions start a query and return its handle. They take the `QueryClient` 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` never throws: errors surface where the handle is read. The other two reject when the query fails without data, so a loader can redirect or show an error page. +`experimental_prefetchHandle` never throws: 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' @@ -99,13 +99,13 @@ function Post(props: { post: experimental_QueryHandle }) { [//]: # 'Reading' -`select`'s argument is typed from the factory, as with any `queryOptions` result. 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. +The factory types `select`'s argument, as with any `queryOptions` result. 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. -Handles from `experimental_ensureHandle` and `experimental_fetchHandle` carry the data they waited for, and `experimental_fromHandle` passes it on as `initialData` with its `initialDataUpdatedAt`. `useQuery` then always has data, even in a client that never saw the loader's cache, and its `data` is typed as defined. Data already in the cache wins over the handle's data. For a handle from `experimental_prefetchHandle`, `useQuery` data stays possibly `undefined`. +Handles from `experimental_ensureHandle` and `experimental_fetchHandle` carry the data they waited for, and `experimental_fromHandle` passes it on as `initialData` with its `initialDataUpdatedAt`. `useQuery` then always has data, even in a client that never saw the loader's cache, and types its `data` as defined. Data already in the cache wins over the handle's data. For a handle from `experimental_prefetchHandle`, `useQuery` data stays possibly `undefined`. ## Waterfalls: `experimental_waterfallHandle` -Sometimes the input is only known 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 it can be called anywhere. +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: @@ -282,6 +282,6 @@ export function dehydrateUnsent(queryClient: QueryClient) { [//]: # 'NestedDehydrateDont' -A module is shared by every request the server handles. 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. +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. -A query is sent once, with the state it has when it is first dehydrated. If it changes later in the same request, the change doesn't reach the browser. +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 index 26bf6a06600..b8230226995 100644 --- a/packages/query-core/src/__tests__/handles.test.tsx +++ b/packages/query-core/src/__tests__/handles.test.tsx @@ -319,7 +319,7 @@ describe('query handles', () => { }) }) - it('should let data already in the cache win over the handle data', () => { + it("should prefer cached data over the handle's data", () => { using _timers = fakeTimers() using queryClient = clearOnDispose(new QueryClient()) const postQuery = createPostQuery() @@ -369,7 +369,7 @@ describe('query handles', () => { }) }) - it('should return the handle it switches from while the switched input maps to its query', () => { + 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() diff --git a/packages/query-core/src/handles.ts b/packages/query-core/src/handles.ts index 7864709ac1d..81d7ff71b2d 100644 --- a/packages/query-core/src/handles.ts +++ b/packages/query-core/src/handles.ts @@ -13,7 +13,7 @@ import type { // TYPES /** - * The options a query factory returns, as built by `queryOptions` or `infiniteQueryOptions`. + * The options a query factory returns, usually from `queryOptions` or `infiniteQueryOptions`. */ interface QueryFactoryOptions { queryKey: QueryKey @@ -21,8 +21,8 @@ interface QueryFactoryOptions { } /** - * A function of zero or one argument that returns the options of a query, usually built with - * `queryOptions` or `infiniteQueryOptions`. Its argument is the query's input, which handles carry. + * 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 @@ -33,8 +33,8 @@ export type QueryHandleInput = Parameters extends [] ? undefined : Parameters[0] /** - * The data of a query built by a factory, as tagged on its `queryKey`. For an infinite query - * factory this is the `InfiniteData` of its pages, with the type of its `initialPageParam`. + * The data type of a factory's query, from the tag on its `queryKey`. For an infinite query + * factory, this is the `InfiniteData` of its pages, with the type of its `initialPageParam`. */ export type QueryHandleData = ReturnType extends { initialPageParam: infer TPageParam } @@ -47,7 +47,7 @@ export type QueryHandleData = : InferDataFromTag['queryKey']> /** - * The error type of a query built by a factory, as tagged on its `queryKey`. + * The error type of a factory's query, from the tag on its `queryKey`. */ export type QueryHandleError = InferErrorFromTag['queryKey']> @@ -86,7 +86,7 @@ export interface QueryHandle< */ readonly queryKey: QueryHandleKey /** - * The input the query was started with: the argument passed to its factory. + * The query's input: the argument its factory takes. */ readonly input: QueryHandleInput } @@ -104,7 +104,7 @@ export interface QueryHandleWithData< */ readonly data: QueryHandleData /** - * When `data` was last updated, passed on as `initialDataUpdatedAt`. + * When `data` last changed. `experimental_fromHandle` passes it on as `initialDataUpdatedAt`. */ readonly dataUpdatedAt: number } @@ -127,7 +127,7 @@ export type FromHandleOptions< // FUNCTIONS /** - * Tells options built by `infiniteQueryOptions` from options built by `queryOptions`. + * Tells `infiniteQueryOptions` results apart from `queryOptions` results. * @param options - Options a query factory returned. * @returns Whether they describe an infinite query. */ @@ -136,7 +136,7 @@ function isInfiniteOptions(options: QueryFactoryOptions): boolean { } /** - * Starts a query built by a factory, without waiting. + * 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. @@ -168,7 +168,7 @@ function getQuery( /** * Builds a handle. * @param options - Options a query factory returned. - * @param input - The input they were built from. + * @param input - The factory's input. * @returns A handle with their key and the input. */ function toHandle(options: QueryFactoryOptions, input: unknown) { @@ -177,9 +177,9 @@ function toHandle(options: QueryFactoryOptions, input: unknown) { /** * Builds a handle that carries the query's current data. - * @param client - The client the query is cached on. + * @param client - The client whose cache holds the query. * @param options - Options a query factory returned. - * @param input - The input they were built from. + * @param input - The factory's input. * @returns A handle with their key, the input and the data. */ function withData( @@ -196,10 +196,10 @@ function withData( } /** - * Starts a query without waiting and returns a serializable handle for it, for a route loader, a - * server component or a server render to pass to the component that reads the query. Like - * `queryClient.query`, it fetches only if the cache has no fresh data, and it never throws: errors - * surface where the handle is read. + * 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 never throws: 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. @@ -230,7 +230,7 @@ export function experimental_prefetchHandle< /** * Starts a query and waits until the cache has data for it, then returns a handle that carries the - * data. Cached data resolves at once, even if it is stale, and stale data is refetched in the + * data. 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. * @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. @@ -265,7 +265,7 @@ export async function experimental_ensureHandle< /** * Starts a query and waits for fresh data, then returns a handle that carries the data. Like - * `queryClient.query`, cached data is used only if it isn't stale. Rejects if the fetch fails. + * `queryClient.query`, it uses cached data only if it isn't stale. Rejects if the fetch fails. * @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. @@ -293,7 +293,7 @@ export async function experimental_fetchHandle< } /** - * Builds the handle of a query whose input is only known during render, like a search term or a + * 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`. @@ -317,8 +317,8 @@ export function experimental_waterfallHandle< * handle does, when it renders, which makes it a request waterfall. Prefer passing the new input * to the loader through the URL. * - * While the switched input maps to the handle's own query, the handle is returned as is, so the - * first page reads what the loader started. + * 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. * @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. @@ -376,8 +376,8 @@ export function experimental_waterfallHandle< * that takes query options, and spread them to add read-site options like `select`. They share the * cache entry the handle's verb started. * - * For a handle from `experimental_ensureHandle` or `experimental_fetchHandle`, the handle's data - * is passed on as `initialData` and `initialDataUpdatedAt`, so readers always have data. Data + * For a handle from `experimental_ensureHandle` or `experimental_fetchHandle`, it passes the + * handle's data on as `initialData` and `initialDataUpdatedAt`, so readers always have data. Data * already in the cache wins over it. * @param factory - The factory that started the query. * @param handle - The handle the factory's verb returned. diff --git a/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts b/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts index beeb1557670..c50b7ed1d89 100644 --- a/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts +++ b/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { clearOnDispose } from '../clearOnDispose' describe('clearOnDispose', () => { - it('should clear the client when it is disposed', () => { + it('should clear the client on dispose', () => { const clear = vi.fn<() => void>() { using client = clearOnDispose({ clear }) diff --git a/packages/query-test-utils/src/__tests__/fakeTimers.test.ts b/packages/query-test-utils/src/__tests__/fakeTimers.test.ts index 75e1cdbab80..0bbf2f6090b 100644 --- a/packages/query-test-utils/src/__tests__/fakeTimers.test.ts +++ b/packages/query-test-utils/src/__tests__/fakeTimers.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { fakeTimers } from '../fakeTimers' describe('fakeTimers', () => { - it('should use fake timers until it is disposed', () => { + it('should switch back to real timers on dispose', () => { { using _timers = fakeTimers() expect(vi.isFakeTimers()).toBe(true) diff --git a/packages/query-test-utils/src/__tests__/unmountOnDispose.test.ts b/packages/query-test-utils/src/__tests__/unmountOnDispose.test.ts index 9ced68c140b..ea9ebedef41 100644 --- a/packages/query-test-utils/src/__tests__/unmountOnDispose.test.ts +++ b/packages/query-test-utils/src/__tests__/unmountOnDispose.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { unmountOnDispose } from '../unmountOnDispose' describe('unmountOnDispose', () => { - it('should unmount the rendered tree when it is disposed', () => { + it('should unmount on dispose', () => { const unmount = vi.fn<() => void>() { using rendered = unmountOnDispose({ unmount }) diff --git a/packages/query-test-utils/src/clearOnDispose.ts b/packages/query-test-utils/src/clearOnDispose.ts index 8e2eb4c3019..9bca266bff4 100644 --- a/packages/query-test-utils/src/clearOnDispose.ts +++ b/packages/query-test-utils/src/clearOnDispose.ts @@ -1,7 +1,6 @@ /** - * Makes a client, like a `QueryClient`, clear its caches when it is disposed, for a test to hold - * with `using`. - * @param client - The client to clear on dispose. + * 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 diff --git a/packages/query-test-utils/src/fakeTimers.ts b/packages/query-test-utils/src/fakeTimers.ts index c88c18fb89b..5988b14d8b7 100644 --- a/packages/query-test-utils/src/fakeTimers.ts +++ b/packages/query-test-utils/src/fakeTimers.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest' /** - * Switches to fake timers until the returned object is disposed, for a test to hold with `using`. + * Switches to fake timers until a test's `using` declaration disposes the result. * @returns A disposable that switches back to real timers. * @example * ```ts diff --git a/packages/query-test-utils/src/unmountOnDispose.ts b/packages/query-test-utils/src/unmountOnDispose.ts index 325a9b71c1d..f99432faad7 100644 --- a/packages/query-test-utils/src/unmountOnDispose.ts +++ b/packages/query-test-utils/src/unmountOnDispose.ts @@ -1,8 +1,8 @@ /** - * Makes a rendered tree, like the result of Testing Library's `render`, unmount when it is - * disposed, for a test to hold with `using`. Declared after the client it renders with, it - * unmounts before that client is cleared. - * @param rendered - The render result to unmount on dispose. + * 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 diff --git a/packages/react-query/src/__tests__/trpcLikeProxy.ts b/packages/react-query/src/__tests__/trpcLikeProxy.ts index c436606969e..18c1c464596 100644 --- a/packages/react-query/src/__tests__/trpcLikeProxy.ts +++ b/packages/react-query/src/__tests__/trpcLikeProxy.ts @@ -14,7 +14,7 @@ import type { experimental_QueryHandleWithData, } from '..' -// A minimal tRPC-like options proxy, built only on the public handle APIs +// A small tRPC-like options proxy that uses only the public handle APIs type AnyProcedure = (input: any) => Promise diff --git a/packages/react-query/src/__tests__/waterfallHandle.test.tsx b/packages/react-query/src/__tests__/waterfallHandle.test.tsx index 3b0e67cd2a2..a5146980193 100644 --- a/packages/react-query/src/__tests__/waterfallHandle.test.tsx +++ b/packages/react-query/src/__tests__/waterfallHandle.test.tsx @@ -35,7 +35,7 @@ describe('experimental_waterfallHandle', () => { return { postsQuery, queryFn } } - it('should let the reader fetch the query for an input, once', async () => { + it('should let the reader fetch the query once', async () => { using _timers = fakeTimers() using queryClient = clearOnDispose(new QueryClient()) const { postsQuery, queryFn } = createPostsQuery() @@ -63,7 +63,7 @@ describe('experimental_waterfallHandle', () => { expect(queryFn).toHaveBeenCalledTimes(1) }) - it('should keep the handle a loader passed down until the switched input maps to another query', async () => { + 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() From 5567de7a7e06c7c5c2de4d066317b2c95d491e86 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 14:30:18 +0000 Subject: [PATCH 08/12] fix(query-core): take only the factory's input in the handle functions Also keep optional elements in a handle's key type, and test streaming a pending query, infinite fetchHandle and server clients. The guide now covers promise transports and data that goes out twice. Co-authored-by: alexander --- .changeset/experimental-query-handles.md | 2 +- docs/framework/react/guides/query-handles.md | 14 ++- .../query-core/src/__tests__/handles.test.tsx | 91 +++++++++++++++++++ packages/query-core/src/handles.ts | 51 ++++++++--- .../src/__tests__/queryHandles.test-d.tsx | 82 +++++++++++++++++ .../src/__tests__/queryHandles.test.tsx | 73 +++++++++++++++ 6 files changed, 293 insertions(+), 20 deletions(-) create mode 100644 packages/react-query/src/__tests__/queryHandles.test.tsx diff --git a/.changeset/experimental-query-handles.md b/.changeset/experimental-query-handles.md index a360093eed8..448e781911b 100644 --- a/.changeset/experimental-query-handles.md +++ b/.changeset/experimental-query-handles.md @@ -3,4 +3,4 @@ '@tanstack/react-query': minor --- -feat: 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. +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. diff --git a/docs/framework/react/guides/query-handles.md b/docs/framework/react/guides/query-handles.md index 2e34ae7cc7d..2864e6afedb 100644 --- a/docs/framework/react/guides/query-handles.md +++ b/docs/framework/react/guides/query-handles.md @@ -5,7 +5,7 @@ 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 and don't start requests of their own. +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. @@ -48,7 +48,7 @@ Three functions start a query and return its handle. They take the `QueryClient` 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` never throws: 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. +`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' @@ -103,6 +103,8 @@ The factory types `select`'s argument, as with any `queryOptions` result. Keep o Handles from `experimental_ensureHandle` and `experimental_fetchHandle` carry the data they waited for, and `experimental_fromHandle` passes it on as `initialData` with its `initialDataUpdatedAt`. `useQuery` then always has data, even in a client that never saw the loader's cache, and types its `data` as defined. Data already in the cache wins over the handle's data. For a handle from `experimental_prefetchHandle`, `useQuery` data stays possibly `undefined`. +If the page sends its loader data and dehydrated state as plain JSON, or `serializeData` copies the data, an awaited handle's data goes out twice: once in the handle and once in the dehydrated state. + ## 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. @@ -146,21 +148,23 @@ function Posts(props: { posts: experimental_QueryHandle }) { [//]: # '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. Prefer putting such inputs in the URL so the loader can start them, and use `experimental_waterfallHandle` when that isn't practical. +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 -Handles work with the [hydration APIs](./advanced-ssr.md) like any other query. +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`: +`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: { diff --git a/packages/query-core/src/__tests__/handles.test.tsx b/packages/query-core/src/__tests__/handles.test.tsx index b8230226995..a9fafd66843 100644 --- a/packages/query-core/src/__tests__/handles.test.tsx +++ b/packages/query-core/src/__tests__/handles.test.tsx @@ -8,6 +8,7 @@ import { import { QueryClient, QueryObserver, + environmentManager, experimental_ensureHandle, experimental_fetchHandle, experimental_fromHandle, @@ -15,6 +16,17 @@ import { experimental_waterfallHandle, } 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), @@ -244,6 +256,85 @@ describe('query handles', () => { 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] }) + + const handle = await experimental_fetchHandle(queryClient, feedQuery, { + pages: 2, + }) + + expect(handle.data).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 + const ensured = await experimental_ensureHandle(queryClient, postQuery, { + id: '1', + }) + expect(ensured.data).toBe('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.toMatchObject({ data: '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', () => { diff --git a/packages/query-core/src/handles.ts b/packages/query-core/src/handles.ts index 81d7ff71b2d..4e7d91cf139 100644 --- a/packages/query-core/src/handles.ts +++ b/packages/query-core/src/handles.ts @@ -32,6 +32,18 @@ export type QueryHandleFactory = (input?: any) => QueryFactoryOptions export 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]] + /** * The data type of a factory's query, from the tag on its `queryKey`. For an infinite query * factory, this is the `InfiniteData` of its pages, with the type of its `initialPageParam`. @@ -52,14 +64,24 @@ export type QueryHandleData = export type QueryHandleError = InferErrorFromTag['queryKey']> -type PlainQueryKey< +type PlainTuple< TQueryKey extends ReadonlyArray, - TItems extends Array = [], -> = number extends TQueryKey['length'] - ? ReadonlyArray - : TItems['length'] extends TQueryKey['length'] - ? Readonly - : PlainQueryKey + 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 @@ -198,8 +220,8 @@ function withData( /** * 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 never throws: errors - * show up where a component reads the handle. + * `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. @@ -221,7 +243,7 @@ export function experimental_prefetchHandle< >( queryClient: QueryClient, factory: TFactory, - ...input: Parameters + ...input: QueryHandleArgs ): QueryHandle { const options = factory(...input) void startQueryFromFactory(queryClient, options).catch(noop) @@ -247,7 +269,7 @@ export async function experimental_ensureHandle< >( queryClient: QueryClient, factory: TFactory, - ...input: Parameters + ...input: QueryHandleArgs ): Promise> { const options = factory(...input) const promise = startQueryFromFactory(queryClient, options) @@ -281,7 +303,7 @@ export async function experimental_fetchHandle< >( queryClient: QueryClient, factory: TFactory, - ...input: Parameters + ...input: QueryHandleArgs ): Promise> { const options = factory(...input) await startQueryFromFactory(queryClient, options) @@ -310,7 +332,7 @@ export async function experimental_fetchHandle< */ export function experimental_waterfallHandle< TFactory extends QueryHandleFactory, ->(factory: TFactory, ...input: Parameters): QueryHandle +>(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 @@ -318,7 +340,8 @@ export function experimental_waterfallHandle< * 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. + * 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. diff --git a/packages/react-query/src/__tests__/queryHandles.test-d.tsx b/packages/react-query/src/__tests__/queryHandles.test-d.tsx index 0e81afd6d28..6689bed14f6 100644 --- a/packages/react-query/src/__tests__/queryHandles.test-d.tsx +++ b/packages/react-query/src/__tests__/queryHandles.test-d.tsx @@ -64,6 +64,88 @@ describe('query handles', () => { ) }) + 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', 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) + }) +}) From 358d6a45b17ac9d2c99ca24c59735b9af3fbe488 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 14:51:25 +0000 Subject: [PATCH 09/12] feat(query-core): export only the handle types integrations need Keep experimental_QueryHandle, experimental_QueryHandleWithData, experimental_QueryHandleFactory and experimental_FromHandleOptions. The guide and tests now cover a handle's data seeding the cache again after clear(), removeQueries or resetQueries, as any initialData does. Co-authored-by: alexander --- docs/framework/react/guides/query-handles.md | 2 + .../query-core/src/__tests__/handles.test.tsx | 51 +++++++++++++++++++ packages/query-core/src/handles.ts | 14 ++--- packages/query-core/src/index.ts | 4 -- .../src/__tests__/subscribe.test.ts | 22 ++++++++ packages/query-test-utils/src/index.ts | 1 + packages/query-test-utils/src/subscribe.ts | 23 +++++++++ 7 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 packages/query-test-utils/src/__tests__/subscribe.test.ts create mode 100644 packages/query-test-utils/src/subscribe.ts diff --git a/docs/framework/react/guides/query-handles.md b/docs/framework/react/guides/query-handles.md index 2864e6afedb..630d2874638 100644 --- a/docs/framework/react/guides/query-handles.md +++ b/docs/framework/react/guides/query-handles.md @@ -103,6 +103,8 @@ The factory types `select`'s argument, as with any `queryOptions` result. Keep o Handles from `experimental_ensureHandle` and `experimental_fetchHandle` carry the data they waited for, and `experimental_fromHandle` passes it on as `initialData` with its `initialDataUpdatedAt`. `useQuery` then always has data, even in a client that never saw the loader's cache, and types its `data` as defined. Data already in the cache wins over the handle's data. For a handle from `experimental_prefetchHandle`, `useQuery` data stays possibly `undefined`. +As with any `initialData`, a mounted reader puts the handle's data back in the cache after `queryClient.clear()` or `removeQueries`, and `resetQueries` resets the query to it. The handle lives as long as the router keeps the loader data, so a logout that clears the cache should also drop the loader data, for example by invalidating the route. + If the page sends its loader data and dehydrated state as plain JSON, or `serializeData` copies the data, an awaited handle's data goes out twice: once in the handle and once in the dehydrated state. ## Waterfalls: `experimental_waterfallHandle` diff --git a/packages/query-core/src/__tests__/handles.test.tsx b/packages/query-core/src/__tests__/handles.test.tsx index a9fafd66843..87a2d38013a 100644 --- a/packages/query-core/src/__tests__/handles.test.tsx +++ b/packages/query-core/src/__tests__/handles.test.tsx @@ -4,6 +4,7 @@ import { createKey, fakeTimers, sleep, + subscribe, } from '@tanstack/query-test-utils' import { QueryClient, @@ -14,6 +15,7 @@ import { experimental_fromHandle, experimental_prefetchHandle, experimental_waterfallHandle, + noop, } from '..' const isServer = environmentManager.isServer() @@ -429,6 +431,55 @@ describe('query handles', () => { expect(observer.getCurrentResult().data).toBe('newer') }) + + it("should seed the cache from a handle's data again after clear(), like any initialData", () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = { + queryKey: postQuery({ id: '1' }).queryKey, + input: { id: '1' }, + data: 'from the loader', + dataUpdatedAt: Date.now(), + } + const options = { + ...experimental_fromHandle(postQuery, handle), + enabled: false, + } + const observer = new QueryObserver(queryClient, options) + using _subscription = subscribe(observer, noop) + + queryClient.clear() + expect(queryClient.getQueryData(handle.queryKey)).toBeUndefined() + // The reader renders again + observer.setOptions(options) + + expect(queryClient.getQueryData(handle.queryKey)).toBe('from the loader') + }) + + it("should reset the query to a handle's data, like any initialData", async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = { + queryKey: postQuery({ id: '1' }).queryKey, + input: { id: '1' }, + data: 'from the loader', + dataUpdatedAt: Date.now(), + } + using _subscription = subscribe( + new QueryObserver(queryClient, { + ...experimental_fromHandle(postQuery, handle), + enabled: false, + }), + noop, + ) + queryClient.setQueryData(handle.queryKey, 'newer') + + await queryClient.resetQueries({ queryKey: handle.queryKey }) + + expect(queryClient.getQueryData(handle.queryKey)).toBe('from the loader') + }) }) describe('experimental_waterfallHandle', () => { diff --git a/packages/query-core/src/handles.ts b/packages/query-core/src/handles.ts index 4e7d91cf139..c4d18d05ed4 100644 --- a/packages/query-core/src/handles.ts +++ b/packages/query-core/src/handles.ts @@ -2,9 +2,7 @@ import { hashQueryKeyByOptions, noop } from './utils' import type { QueryClient } from './queryClient' import type { Query } from './query' import type { - DefaultError, InferDataFromTag, - InferErrorFromTag, InfiniteData, QueryKey, QueryKeyHashFunction, @@ -29,7 +27,7 @@ export type QueryHandleFactory = (input?: any) => QueryFactoryOptions /** * The input of a query factory: its first parameter, or `undefined` if it takes none. */ -export type QueryHandleInput = +type QueryHandleInput = Parameters extends [] ? undefined : Parameters[0] /** @@ -48,7 +46,7 @@ type QueryHandleArgs = * The data type of a factory's query, from the tag on its `queryKey`. For an infinite query * factory, this is the `InfiniteData` of its pages, with the type of its `initialPageParam`. */ -export type QueryHandleData = +type QueryHandleData = ReturnType extends { initialPageParam: infer TPageParam } ? InferDataFromTag< unknown, @@ -58,12 +56,6 @@ export type QueryHandleData = : InferDataFromTag['queryKey']> : InferDataFromTag['queryKey']> -/** - * The error type of a factory's query, from the tag on its `queryKey`. - */ -export type QueryHandleError = - InferErrorFromTag['queryKey']> - type PlainTuple< TQueryKey extends ReadonlyArray, TRequired extends Array = [], @@ -88,7 +80,7 @@ type PlainQueryKey> = * `queryOptions` adds, so a handle stays a plain serializable object for routers that check * loader data. */ -export type QueryHandleKey = PlainQueryKey< +type QueryHandleKey = PlainQueryKey< ReturnType['queryKey'] > diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 1cec378e34f..64460815d6e 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -55,11 +55,7 @@ export { export type { FromHandleOptions as experimental_FromHandleOptions, QueryHandle as experimental_QueryHandle, - QueryHandleData as experimental_QueryHandleData, - QueryHandleError as experimental_QueryHandleError, QueryHandleFactory as experimental_QueryHandleFactory, - QueryHandleInput as experimental_QueryHandleInput, - QueryHandleKey as experimental_QueryHandleKey, QueryHandleWithData as experimental_QueryHandleWithData, } from './handles' 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/index.ts b/packages/query-test-utils/src/index.ts index f837bd7e936..f704418400a 100644 --- a/packages/query-test-utils/src/index.ts +++ b/packages/query-test-utils/src/index.ts @@ -3,4 +3,5 @@ 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() + }, + } +} From f9b843e4a3651deb110f8f02aab628e7a9377591 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 15:38:29 +0000 Subject: [PATCH 10/12] feat(query-core): drop the data from query handles Every handle is now a plain { queryKey, input }. experimental_ensureHandle and experimental_fetchHandle still wait for the data, but return the same handle as experimental_prefetchHandle, and experimental_fromHandle adds no initialData. Readers that need defined data use useSuspenseQuery. Co-authored-by: alexander --- docs/framework/react/guides/query-handles.md | 25 ++- .../query-core/src/__tests__/handles.test.tsx | 130 ++++------------ packages/query-core/src/handles.ts | 144 ++++-------------- packages/query-core/src/index.ts | 1 - .../src/__tests__/queryHandles.test-d.tsx | 115 +++++++------- .../src/__tests__/trpcLikeProxy.test-d.tsx | 15 +- .../src/__tests__/trpcLikeProxy.test.tsx | 2 +- .../src/__tests__/trpcLikeProxy.ts | 9 +- .../src/__tests__/waterfallHandle.test.tsx | 5 +- 9 files changed, 140 insertions(+), 306 deletions(-) diff --git a/docs/framework/react/guides/query-handles.md b/docs/framework/react/guides/query-handles.md index 630d2874638..cbda1869db2 100644 --- a/docs/framework/react/guides/query-handles.md +++ b/docs/framework/react/guides/query-handles.md @@ -40,11 +40,13 @@ A handle's type comes from its factory's key. Declare keys with `as const`: two 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 | Handle carries data | -| ----------------------------- | ----------------------------------- | ------------------- | -| `experimental_prefetchHandle` | nothing | no | -| `experimental_ensureHandle` | any data, cached data even if stale | yes | -| `experimental_fetchHandle` | fresh data | yes | +| 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. @@ -59,12 +61,9 @@ import { } from '@tanstack/react-query' export async function loader({ params }) { - // The page can't render without the viewer, so wait for it - const viewer = await experimental_ensureHandle(queryClient, viewerQuery) - if (!viewer.data.isAdmin) throw redirect('/') - return { - viewer, + // 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, @@ -101,11 +100,7 @@ function Post(props: { post: experimental_QueryHandle }) { The factory types `select`'s argument, as with any `queryOptions` result. 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. -Handles from `experimental_ensureHandle` and `experimental_fetchHandle` carry the data they waited for, and `experimental_fromHandle` passes it on as `initialData` with its `initialDataUpdatedAt`. `useQuery` then always has data, even in a client that never saw the loader's cache, and types its `data` as defined. Data already in the cache wins over the handle's data. For a handle from `experimental_prefetchHandle`, `useQuery` data stays possibly `undefined`. - -As with any `initialData`, a mounted reader puts the handle's data back in the cache after `queryClient.clear()` or `removeQueries`, and `resetQueries` resets the query to it. The handle lives as long as the router keeps the loader data, so a logout that clears the cache should also drop the loader data, for example by invalidating the route. - -If the page sends its loader data and dehydrated state as plain JSON, or `serializeData` copies the data, an awaited handle's data goes out twice: once in the handle and once in the dehydrated state. +Readers get the query's data from the cache. To get defined `data`, read the handle with `useSuspenseQuery`, which waits for the 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` diff --git a/packages/query-core/src/__tests__/handles.test.tsx b/packages/query-core/src/__tests__/handles.test.tsx index 87a2d38013a..5784262a692 100644 --- a/packages/query-core/src/__tests__/handles.test.tsx +++ b/packages/query-core/src/__tests__/handles.test.tsx @@ -145,7 +145,7 @@ describe('query handles', () => { }) describe('experimental_ensureHandle', () => { - it('should wait for the data and carry it', async () => { + it('should wait for the data and return a plain handle', async () => { using _timers = fakeTimers() using queryClient = clearOnDispose(new QueryClient()) const postQuery = createPostQuery() @@ -159,9 +159,8 @@ describe('query handles', () => { expect(handle).toStrictEqual({ queryKey: postQuery({ id: '1' }).queryKey, input: { id: '1' }, - data: '1', - dataUpdatedAt: Date.now(), }) + expect(queryClient.getQueryData(handle.queryKey)).toBe('1') }) it('should load the pages an infinite factory asks for', async () => { @@ -176,11 +175,9 @@ describe('query handles', () => { pages: input.pages, }) - const handle = await experimental_ensureHandle(queryClient, feedQuery, { - pages: 3, - }) + await experimental_ensureHandle(queryClient, feedQuery, { pages: 3 }) - expect(handle.data).toStrictEqual({ + expect(queryClient.getQueryData(key)).toStrictEqual({ pages: [10, 20, 30], pageParams: [1, 2, 3], }) @@ -195,11 +192,11 @@ describe('query handles', () => { const postQuery = createPostQuery(queryFn) queryClient.setQueryData(postQuery({ id: '1' }).queryKey, 'cached') - const handle = await experimental_ensureHandle(queryClient, postQuery, { - id: '1', - }) + await experimental_ensureHandle(queryClient, postQuery, { id: '1' }) - expect(handle.data).toBe('cached') + 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( @@ -218,7 +215,7 @@ describe('query handles', () => { }) await vi.advanceTimersByTimeAsync(0) - expect(handle.data).toBe('cached') + expect(queryClient.getQueryData(handle.queryKey)).toBe('cached') }) it('should reject when the query has no data and fails', async () => { @@ -245,8 +242,9 @@ describe('query handles', () => { id: '1', }) await vi.advanceTimersByTimeAsync(10) + const handle = await promise - await expect(promise).resolves.toMatchObject({ data: '1-new' }) + expect(queryClient.getQueryData(handle.queryKey)).toBe('1-new') }) it('should reject when the fetch fails', async () => { @@ -272,11 +270,9 @@ describe('query handles', () => { }) queryClient.setQueryData(key, { pages: [10], pageParams: [1] }) - const handle = await experimental_fetchHandle(queryClient, feedQuery, { - pages: 2, - }) + await experimental_fetchHandle(queryClient, feedQuery, { pages: 2 }) - expect(handle.data).toStrictEqual({ + expect(queryClient.getQueryData(key)).toStrictEqual({ pages: [10, 20], pageParams: [1, 2], }) @@ -298,10 +294,7 @@ describe('query handles', () => { expect(queryFn).toHaveBeenCalledTimes(1) // staleTime defaults to 0, so the data is already stale - const ensured = await experimental_ensureHandle(queryClient, postQuery, { - id: '1', - }) - expect(ensured.data).toBe('1') + await experimental_ensureHandle(queryClient, postQuery, { id: '1' }) expect(queryFn).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(10) @@ -310,7 +303,10 @@ describe('query handles', () => { }) expect(queryFn).toHaveBeenCalledTimes(3) await vi.advanceTimersByTimeAsync(10) - await expect(fetched).resolves.toMatchObject({ data: '1' }) + await expect(fetched).resolves.toStrictEqual({ + queryKey: postQuery({ id: '1' }).queryKey, + input: { id: '1' }, + }) }) it('should not refetch data that is fresh by staleTime', async () => { @@ -374,7 +370,7 @@ describe('query handles', () => { expect(observer.getCurrentResult().data).toBe(1) }) - it("should pass an awaited handle's data on as initialData", async () => { + it('should add no initialData for an awaited handle', async () => { using _timers = fakeTimers() using queryClient = clearOnDispose(new QueryClient()) const postQuery = createPostQuery() @@ -386,62 +382,19 @@ describe('query handles', () => { const options = experimental_fromHandle(postQuery, handle) - expect(options.initialData).toBe('1') - expect(options.initialDataUpdatedAt).toBe(handle.dataUpdatedAt) + expect(options).not.toHaveProperty('initialData') + expect(options).not.toHaveProperty('initialDataUpdatedAt') }) - it("should give a new client the handle's data", () => { + 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 handle = { - queryKey: postQuery({ id: '1' }).queryKey, - input: { id: '1' }, - data: 'from the loader', - dataUpdatedAt: Date.now(), - } - - const observer = new QueryObserver(queryClient, { - ...experimental_fromHandle(postQuery, handle), - enabled: false, - }) - - expect(observer.getCurrentResult()).toMatchObject({ - status: 'success', - data: 'from the loader', + const promise = experimental_fetchHandle(queryClient, postQuery, { + id: '1', }) - }) - - it("should prefer cached data over the handle's data", () => { - using _timers = fakeTimers() - using queryClient = clearOnDispose(new QueryClient()) - const postQuery = createPostQuery() - const handle = { - queryKey: postQuery({ id: '1' }).queryKey, - input: { id: '1' }, - data: 'from the loader', - dataUpdatedAt: Date.now() - 1000, - } - queryClient.setQueryData(handle.queryKey, 'newer') - - const observer = new QueryObserver( - queryClient, - experimental_fromHandle(postQuery, handle), - ) - - expect(observer.getCurrentResult().data).toBe('newer') - }) - - it("should seed the cache from a handle's data again after clear(), like any initialData", () => { - using _timers = fakeTimers() - using queryClient = clearOnDispose(new QueryClient()) - const postQuery = createPostQuery() - const handle = { - queryKey: postQuery({ id: '1' }).queryKey, - input: { id: '1' }, - data: 'from the loader', - dataUpdatedAt: Date.now(), - } + await vi.advanceTimersByTimeAsync(10) + const handle = await promise const options = { ...experimental_fromHandle(postQuery, handle), enabled: false, @@ -450,35 +403,14 @@ describe('query handles', () => { using _subscription = subscribe(observer, noop) queryClient.clear() - expect(queryClient.getQueryData(handle.queryKey)).toBeUndefined() // The reader renders again observer.setOptions(options) - expect(queryClient.getQueryData(handle.queryKey)).toBe('from the loader') - }) - - it("should reset the query to a handle's data, like any initialData", async () => { - using _timers = fakeTimers() - using queryClient = clearOnDispose(new QueryClient()) - const postQuery = createPostQuery() - const handle = { - queryKey: postQuery({ id: '1' }).queryKey, - input: { id: '1' }, - data: 'from the loader', - dataUpdatedAt: Date.now(), - } - using _subscription = subscribe( - new QueryObserver(queryClient, { - ...experimental_fromHandle(postQuery, handle), - enabled: false, - }), - noop, - ) - queryClient.setQueryData(handle.queryKey, 'newer') - - await queryClient.resetQueries({ queryKey: handle.queryKey }) - - expect(queryClient.getQueryData(handle.queryKey)).toBe('from the loader') + expect(queryClient.getQueryData(handle.queryKey)).toBeUndefined() + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + data: undefined, + }) }) }) diff --git a/packages/query-core/src/handles.ts b/packages/query-core/src/handles.ts index c4d18d05ed4..38246e8ed6d 100644 --- a/packages/query-core/src/handles.ts +++ b/packages/query-core/src/handles.ts @@ -1,12 +1,7 @@ import { hashQueryKeyByOptions, noop } from './utils' import type { QueryClient } from './queryClient' import type { Query } from './query' -import type { - InferDataFromTag, - InfiniteData, - QueryKey, - QueryKeyHashFunction, -} from './types' +import type { QueryKey, QueryKeyHashFunction } from './types' // TYPES @@ -42,20 +37,6 @@ type QueryHandleArgs = ? [input: TInput] : [input?: Parameters[0]] -/** - * The data type of a factory's query, from the tag on its `queryKey`. For an infinite query - * factory, this is the `InfiniteData` of its pages, with the type of its `initialPageParam`. - */ -type QueryHandleData = - ReturnType extends { initialPageParam: infer TPageParam } - ? InferDataFromTag< - unknown, - ReturnType['queryKey'] - > extends InfiniteData - ? InfiniteData - : InferDataFromTag['queryKey']> - : InferDataFromTag['queryKey']> - type PlainTuple< TQueryKey extends ReadonlyArray, TRequired extends Array = [], @@ -85,9 +66,11 @@ type QueryHandleKey = PlainQueryKey< > /** - * A small serializable reference to a query, from `experimental_prefetchHandle` or - * `experimental_waterfallHandle`. Pass it to the component that reads the query, and rebuild the - * query's options there with `experimental_fromHandle(factory, handle)`. + * 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. @@ -106,37 +89,10 @@ export interface QueryHandle< } /** - * A handle returned by `experimental_ensureHandle` or `experimental_fetchHandle`, which waited for - * the query's data. It carries that data, so code that called the verb can use it, and - * `experimental_fromHandle` passes it on as `initialData` so readers always have data. + * The options `experimental_fromHandle` returns: the factory's options for the handle's input. */ -export interface QueryHandleWithData< - TFactory extends QueryHandleFactory = QueryHandleFactory, -> extends QueryHandle { - /** - * The query's data when the verb resolved. Readers get the cache's current data instead. - */ - readonly data: QueryHandleData - /** - * When `data` last changed. `experimental_fromHandle` passes it on as `initialDataUpdatedAt`. - */ - readonly dataUpdatedAt: number -} - -/** - * The options `experimental_fromHandle` returns: the factory's options for the handle's input and, - * for a handle with data, that data as `initialData`. - */ -export type FromHandleOptions< - TFactory extends QueryHandleFactory, - THandle extends QueryHandle, -> = - THandle extends QueryHandleWithData - ? Omit, 'initialData'> & { - initialData: QueryHandleData - initialDataUpdatedAt: number - } - : ReturnType +export type FromHandleOptions = + ReturnType // FUNCTIONS @@ -189,26 +145,6 @@ function toHandle(options: QueryFactoryOptions, input: unknown) { return { queryKey: options.queryKey, input } } -/** - * Builds a handle that carries the query's current data. - * @param client - The client whose cache holds the query. - * @param options - Options a query factory returned. - * @param input - The factory's input. - * @returns A handle with their key, the input and the data. - */ -function withData( - client: QueryClient, - options: QueryFactoryOptions, - input: unknown, -) { - const state = getQuery(client, options)?.state - return { - ...toHandle(options, input), - data: state?.data, - dataUpdatedAt: state?.dataUpdatedAt ?? 0, - } -} - /** * 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 @@ -243,17 +179,18 @@ export function experimental_prefetchHandle< } /** - * Starts a query and waits until the cache has data for it, then returns a handle that carries the - * data. 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. + * 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, input and data. + * @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) - * viewer.data.name * ``` */ export async function experimental_ensureHandle< @@ -262,7 +199,7 @@ export async function experimental_ensureHandle< queryClient: QueryClient, factory: TFactory, ...input: QueryHandleArgs -): Promise> { +): Promise> { const options = factory(...input) const promise = startQueryFromFactory(queryClient, options) if (getQuery(queryClient, options)?.state.data === undefined) { @@ -270,24 +207,21 @@ export async function experimental_ensureHandle< } else { promise.catch(noop) } - return withData( - queryClient, - options, - input[0], - ) as QueryHandleWithData + return toHandle(options, input[0]) as QueryHandle } /** - * Starts a query and waits for fresh data, then returns a handle that carries the data. Like - * `queryClient.query`, it uses cached data only if it isn't stale. Rejects if the fetch fails. + * 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, input and data. + * @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 }) - * if (!post.data.published) throw redirect({ to: '/' }) * ``` */ export async function experimental_fetchHandle< @@ -296,14 +230,10 @@ export async function experimental_fetchHandle< queryClient: QueryClient, factory: TFactory, ...input: QueryHandleArgs -): Promise> { +): Promise> { const options = factory(...input) await startQueryFromFactory(queryClient, options) - return withData( - queryClient, - options, - input[0], - ) as QueryHandleWithData + return toHandle(options, input[0]) as QueryHandle } /** @@ -389,11 +319,8 @@ export function experimental_waterfallHandle< * 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. - * - * For a handle from `experimental_ensureHandle` or `experimental_fetchHandle`, it passes the - * handle's data on as `initialData` and `initialDataUpdatedAt`, so readers always have data. Data - * already in the cache wins over it. + * 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. @@ -407,18 +334,9 @@ export function experimental_waterfallHandle< * } * ``` */ -export function experimental_fromHandle< - TFactory extends QueryHandleFactory, - THandle extends QueryHandle, ->(factory: TFactory, handle: THandle): FromHandleOptions { - const options = factory(handle.input) - if ('data' in handle && handle.data !== undefined) { - return { - ...options, - initialData: handle.data, - initialDataUpdatedAt: (handle as unknown as QueryHandleWithData) - .dataUpdatedAt, - } as FromHandleOptions - } - return options as FromHandleOptions +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 64460815d6e..52cf9e52b59 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -56,7 +56,6 @@ export type { FromHandleOptions as experimental_FromHandleOptions, QueryHandle as experimental_QueryHandle, QueryHandleFactory as experimental_QueryHandleFactory, - QueryHandleWithData as experimental_QueryHandleWithData, } from './handles' // Types diff --git a/packages/react-query/src/__tests__/queryHandles.test-d.tsx b/packages/react-query/src/__tests__/queryHandles.test-d.tsx index 6689bed14f6..185f28ceb9c 100644 --- a/packages/react-query/src/__tests__/queryHandles.test-d.tsx +++ b/packages/react-query/src/__tests__/queryHandles.test-d.tsx @@ -14,11 +14,7 @@ import { useSuspenseInfiniteQuery, useSuspenseQuery, } from '..' -import type { - InfiniteData, - experimental_QueryHandle, - experimental_QueryHandleWithData, -} from '..' +import type { InfiniteData, experimental_QueryHandle } from '..' type Post = { id: string; title: string } @@ -162,96 +158,93 @@ describe('query handles', () => { expectTypeOf(handle.input).toEqualTypeOf() }) - it('should resolve awaited handles with defined data', async () => { + 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.data).toEqualTypeOf() - expectTypeOf(ensured.dataUpdatedAt).toEqualTypeOf() - expectTypeOf(fetched.data).toEqualTypeOf<{ name: string }>() - }) - - it('should type infinite handles with InfiniteData', async () => { - const handle = await experimental_ensureHandle(queryClient, feedQuery, { - size: 1, - }) - - expectTypeOf(handle.data).toEqualTypeOf< - InfiniteData, number> + 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 a prefetched handle', () => { - const handle = experimental_prefetchHandle(queryClient, postQuery, { + it('should type useQuery data as possibly undefined for handles from every verb', async () => { + const prefetched = experimental_prefetchHandle(queryClient, postQuery, { id: '1', }) - - const { data } = useQuery(experimental_fromHandle(postQuery, handle)) - - expectTypeOf(data).toEqualTypeOf() - }) - - it('should type useQuery data as defined for an awaited handle', async () => { - const handle = await experimental_fetchHandle(queryClient, postQuery, { + const ensured = await experimental_ensureHandle(queryClient, postQuery, { id: '1', }) - - const { data } = useQuery(experimental_fromHandle(postQuery, handle)) - - expectTypeOf(data).toEqualTypeOf() - }) - - it('should type useSuspenseQuery data as defined for any handle', () => { - const handle = experimental_prefetchHandle(queryClient, postQuery, { + const fetched = await experimental_fetchHandle(queryClient, postQuery, { id: '1', }) + const waterfall = experimental_waterfallHandle(postQuery, { id: '1' }) - const { data } = useSuspenseQuery( - experimental_fromHandle(postQuery, handle), - ) - - expectTypeOf(data).toEqualTypeOf() + 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 select from the factory when read-site options are spread', () => { - const handle = experimental_prefetchHandle(queryClient, postQuery, { + it('should type useSuspenseQuery data as defined for handles from every verb', async () => { + const prefetched = experimental_prefetchHandle(queryClient, postQuery, { id: '1', }) - - const { data } = useSuspenseQuery({ - ...experimental_fromHandle(postQuery, handle), - select: (post) => { - expectTypeOf(post).toEqualTypeOf() - return post.title - }, + 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(data).toEqualTypeOf() + 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 the data of an awaited handle defined when read-site options are spread', async () => { - const handle = await experimental_ensureHandle(queryClient, postQuery, { + it('should type select from the factory when read-site options are spread', () => { + const handle = experimental_prefetchHandle(queryClient, postQuery, { id: '1', }) - const { data } = useQuery({ + const { data } = useSuspenseQuery({ ...experimental_fromHandle(postQuery, handle), select: (post) => { expectTypeOf(post).toEqualTypeOf() return post.title }, - refetchInterval: 1000, }) expectTypeOf(data).toEqualTypeOf() }) - it('should keep the data of a prefetched handle possibly undefined when read-site options are spread', () => { - const handle = experimental_prefetchHandle(queryClient, postQuery, { + it('should keep useQuery data possibly undefined when read-site options are spread', async () => { + const handle = await experimental_fetchHandle(queryClient, postQuery, { id: '1', }) @@ -288,14 +281,14 @@ describe('query handles', () => { expectTypeOf(data).toEqualTypeOf, unknown>>() }) - it('should pass the pages of an awaited infinite handle on as initialData', async () => { + 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>>() + ).toEqualTypeOf, unknown> | undefined>() expectTypeOf( useSuspenseInfiniteQuery(experimental_fromHandle(feedQuery, handle)) .data, @@ -330,14 +323,10 @@ describe('query handles', () => { it('should accept handles typed by their factory', () => { const handle = {} as experimental_QueryHandle - const withData = {} as experimental_QueryHandleWithData expectTypeOf( useQuery(experimental_fromHandle(postQuery, handle)).data, ).toEqualTypeOf() - expectTypeOf( - useQuery(experimental_fromHandle(postQuery, withData)).data, - ).toEqualTypeOf() }) }) diff --git a/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx b/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx index cceddd74b8b..6725f083400 100644 --- a/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx +++ b/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx @@ -40,15 +40,13 @@ describe('a tRPC-like proxy on the public handle APIs', () => { assertType(trpc['post.byId'].$ensureInfinite) }) - it('should give awaited handles defined data', async () => { + 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.data).toEqualTypeOf() + expectTypeOf(post).toEqualTypeOf(trpc['post.byId'].$prefetch({ id: '1' })) + expectTypeOf(post).not.toHaveProperty('data') expectTypeOf(post.input).toEqualTypeOf<{ id: string }>() - expectTypeOf(feed.data).toEqualTypeOf< - InfiniteData - >() expectTypeOf(feed.input).toEqualTypeOf< Omit<{ limit: number; cursor?: number | null }, 'cursor'> >() @@ -65,7 +63,12 @@ describe('a tRPC-like proxy on the public handle APIs', () => { expectTypeOf(useQuery(trpc.$fromHandle(post)).data).toEqualTypeOf< Post | undefined >() - expectTypeOf(useQuery(trpc.$fromHandle(ensured)).data).toEqualTypeOf() + expectTypeOf(useQuery(trpc.$fromHandle(ensured)).data).toEqualTypeOf< + Post | undefined + >() + expectTypeOf( + useSuspenseQuery(trpc.$fromHandle(ensured)).data, + ).toEqualTypeOf() expectTypeOf( useSuspenseInfiniteQuery(trpc.$fromHandle(feed)).data, ).toEqualTypeOf>() diff --git a/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx b/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx index b94cdc7aa52..cb124e78e87 100644 --- a/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx +++ b/packages/react-query/src/__tests__/trpcLikeProxy.test.tsx @@ -61,7 +61,7 @@ describe('a tRPC-like proxy on the public handle APIs', () => { await vi.advanceTimersByTimeAsync(10) const post = await postPromise const comments = serverTrpc['post.comments'].$prefetch({ - postId: post.data.id, + postId: post.input.id, page: 1, }) await vi.advanceTimersByTimeAsync(10) diff --git a/packages/react-query/src/__tests__/trpcLikeProxy.ts b/packages/react-query/src/__tests__/trpcLikeProxy.ts index 18c1c464596..d228e05086b 100644 --- a/packages/react-query/src/__tests__/trpcLikeProxy.ts +++ b/packages/react-query/src/__tests__/trpcLikeProxy.ts @@ -11,7 +11,6 @@ import type { experimental_FromHandleOptions, experimental_QueryHandle, experimental_QueryHandleFactory, - experimental_QueryHandleWithData, } from '..' // A small tRPC-like options proxy that uses only the public handle APIs @@ -84,7 +83,7 @@ type DecorateInfiniteProcedure< input: Omit, opts?: { pages?: number }, ) => Promise< - experimental_QueryHandleWithData< + experimental_QueryHandle< ProcedureInfiniteQueryOptions > > @@ -102,12 +101,12 @@ type DecorateProcedure< $ensure: ( input: InputOf, ) => Promise< - experimental_QueryHandleWithData> + experimental_QueryHandle> > $fetch: ( input: InputOf, ) => Promise< - experimental_QueryHandleWithData> + experimental_QueryHandle> > } & DecorateInfiniteProcedure @@ -160,7 +159,7 @@ export type OptionsProxy = { input: any, ) => { queryKey: ReadonlyArray } ? THandle extends experimental_QueryHandle - ? experimental_FromHandleOptions + ? experimental_FromHandleOptions : never : never } diff --git a/packages/react-query/src/__tests__/waterfallHandle.test.tsx b/packages/react-query/src/__tests__/waterfallHandle.test.tsx index a5146980193..0a349a844a3 100644 --- a/packages/react-query/src/__tests__/waterfallHandle.test.tsx +++ b/packages/react-query/src/__tests__/waterfallHandle.test.tsx @@ -114,7 +114,7 @@ describe('experimental_waterfallHandle', () => { expect(queryFn).toHaveBeenCalledTimes(2) }) - it("should give useQuery an awaited handle's data on the first render", async () => { + 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() @@ -123,7 +123,6 @@ describe('experimental_waterfallHandle', () => { }) await vi.advanceTimersByTimeAsync(10) const handle = await promise - using freshClient = clearOnDispose(new QueryClient()) const statuses: Array = [] function Posts() { @@ -134,7 +133,7 @@ describe('experimental_waterfallHandle', () => { return
data: {data}
} - using rendered = unmountOnDispose(renderWithClient(freshClient, )) + using rendered = unmountOnDispose(renderWithClient(queryClient, )) expect(rendered.getByText('data: page 1')).toBeInTheDocument() expect(statuses[0]).toBe('success') From 46a66c553ce08941fe022c21579d92959bcf877b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 19:30:56 +0000 Subject: [PATCH 11/12] test(react-query): check that select infers through experimental_fromHandle Covers a read-site select spread over the options, a select inside the factory, and a read-site select over a factory that already selects, for useQuery, useSuspenseQuery and the infinite hooks. Co-authored-by: alexander --- docs/framework/react/guides/query-handles.md | 2 +- .../src/__tests__/queryHandles.test-d.tsx | 210 ++++++++++++++++-- 2 files changed, 195 insertions(+), 17 deletions(-) diff --git a/docs/framework/react/guides/query-handles.md b/docs/framework/react/guides/query-handles.md index cbda1869db2..8f78e3c7d25 100644 --- a/docs/framework/react/guides/query-handles.md +++ b/docs/framework/react/guides/query-handles.md @@ -98,7 +98,7 @@ function Post(props: { post: experimental_QueryHandle }) { [//]: # 'Reading' -The factory types `select`'s argument, as with any `queryOptions` result. 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. +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. `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()`. diff --git a/packages/react-query/src/__tests__/queryHandles.test-d.tsx b/packages/react-query/src/__tests__/queryHandles.test-d.tsx index 185f28ceb9c..e8d64d4f5c3 100644 --- a/packages/react-query/src/__tests__/queryHandles.test-d.tsx +++ b/packages/react-query/src/__tests__/queryHandles.test-d.tsx @@ -227,22 +227,6 @@ describe('query handles', () => { ).toEqualTypeOf() }) - it('should type select from the factory when read-site options are spread', () => { - 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 keep useQuery data possibly undefined when read-site options are spread', async () => { const handle = await experimental_fetchHandle(queryClient, postQuery, { id: '1', @@ -362,4 +346,198 @@ describe('query handles', () => { ) }) }) + + 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() + }) + }) }) From b6bc2963405d1d88787be17d679006a458a440af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 19:43:16 +0000 Subject: [PATCH 12/12] feat(query-core): add experimental_InferQueryHandleData It gives the data a reader of a factory's query gets, the same type as useSuspenseQuery's data, so a component can type a prop that takes it. Co-authored-by: alexander --- .changeset/experimental-query-handles.md | 2 +- docs/framework/react/guides/query-handles.md | 2 +- packages/query-core/src/handles.ts | 29 ++++- packages/query-core/src/index.ts | 1 + .../src/__tests__/queryHandles.test-d.tsx | 115 +++++++++++++++++- .../src/__tests__/trpcLikeProxy.test-d.tsx | 27 +++- 6 files changed, 171 insertions(+), 5 deletions(-) diff --git a/.changeset/experimental-query-handles.md b/.changeset/experimental-query-handles.md index 448e781911b..848c1cf324e 100644 --- a/.changeset/experimental-query-handles.md +++ b/.changeset/experimental-query-handles.md @@ -3,4 +3,4 @@ '@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. +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/framework/react/guides/query-handles.md b/docs/framework/react/guides/query-handles.md index 8f78e3c7d25..21d4323200c 100644 --- a/docs/framework/react/guides/query-handles.md +++ b/docs/framework/react/guides/query-handles.md @@ -100,7 +100,7 @@ function Post(props: { post: experimental_QueryHandle }) { 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. `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()`. +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` diff --git a/packages/query-core/src/handles.ts b/packages/query-core/src/handles.ts index 38246e8ed6d..5f3f9c5f1c4 100644 --- a/packages/query-core/src/handles.ts +++ b/packages/query-core/src/handles.ts @@ -1,7 +1,7 @@ import { hashQueryKeyByOptions, noop } from './utils' import type { QueryClient } from './queryClient' import type { Query } from './query' -import type { QueryKey, QueryKeyHashFunction } from './types' +import type { InferDataFromTag, QueryKey, QueryKeyHashFunction } from './types' // TYPES @@ -94,6 +94,33 @@ export interface QueryHandle< 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 /** diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 52cf9e52b59..ab80de65190 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -54,6 +54,7 @@ export { } from './handles' export type { FromHandleOptions as experimental_FromHandleOptions, + InferQueryHandleData as experimental_InferQueryHandleData, QueryHandle as experimental_QueryHandle, QueryHandleFactory as experimental_QueryHandleFactory, } from './handles' diff --git a/packages/react-query/src/__tests__/queryHandles.test-d.tsx b/packages/react-query/src/__tests__/queryHandles.test-d.tsx index e8d64d4f5c3..9dfdaa85a0d 100644 --- a/packages/react-query/src/__tests__/queryHandles.test-d.tsx +++ b/packages/react-query/src/__tests__/queryHandles.test-d.tsx @@ -14,7 +14,11 @@ import { useSuspenseInfiniteQuery, useSuspenseQuery, } from '..' -import type { InfiniteData, experimental_QueryHandle } from '..' +import type { + InfiniteData, + experimental_InferQueryHandleData, + experimental_QueryHandle, +} from '..' type Post = { id: string; title: string } @@ -540,4 +544,113 @@ describe('query handles', () => { 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__/trpcLikeProxy.test-d.tsx b/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx index 6725f083400..def844636b3 100644 --- a/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx +++ b/packages/react-query/src/__tests__/trpcLikeProxy.test-d.tsx @@ -7,7 +7,11 @@ import { useSuspenseQuery, } from '..' import { createOptionsProxy } from './trpcLikeProxy' -import type { InfiniteData, experimental_QueryHandle } from '..' +import type { + InfiniteData, + experimental_InferQueryHandleData, + experimental_QueryHandle, +} from '..' import type { OptionsProxy } from './trpcLikeProxy' type Post = { id: string; title: string } @@ -134,4 +138,25 @@ describe('a tRPC-like proxy on the public handle APIs', () => { 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>() + }) })