From 287ddeb1d07f21480f92bbbca3670b57ceef5f8c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Nov 2026 23:54:33 +0000 Subject: [PATCH 1/5] feat(query-core, query-devtools): show how query handles started their queries in the devtools Co-authored-by: alexander --- .changeset/query-handles-devtools.md | 6 + docs/framework/react/guides/query-handles.md | 12 +- .../src/__tests__/hydration.test-d.tsx | 1 + .../src/__tests__/queryStartInfo.test.tsx | 208 ++++++++++++++++++ packages/query-core/src/handles.ts | 189 +++++++++++++++- packages/query-core/src/hydration.ts | 58 ++++- packages/query-core/src/index.ts | 2 + packages/query-devtools/src/Devtools.tsx | 76 ++++++- .../src/__tests__/Devtools.test.tsx | 98 +++++++++ packages/query-devtools/src/utils.tsx | 35 ++- .../src/__tests__/waterfallHandle.test.tsx | 7 + 11 files changed, 673 insertions(+), 19 deletions(-) create mode 100644 .changeset/query-handles-devtools.md create mode 100644 packages/query-core/src/__tests__/queryStartInfo.test.tsx diff --git a/.changeset/query-handles-devtools.md b/.changeset/query-handles-devtools.md new file mode 100644 index 00000000000..9d31bb1fd44 --- /dev/null +++ b/.changeset/query-handles-devtools.md @@ -0,0 +1,6 @@ +--- +'@tanstack/query-core': minor +'@tanstack/query-devtools': minor +--- + +feat: show how experimental query handles started their queries in the devtools. `experimental_getQueryStartInfo` returns which handle function started a query, where and when, and whether it has been read. The devtools show it, mark queries first fetched by a reader of an `experimental_waterfallHandle` handle and flag prefetched queries that were never read. In development, `dehydrate` warns when it leaves out a pending query that a handle function started on the server. diff --git a/docs/framework/react/guides/query-handles.md b/docs/framework/react/guides/query-handles.md index 21d4323200c..05b80a01f46 100644 --- a/docs/framework/react/guides/query-handles.md +++ b/docs/framework/react/guides/query-handles.md @@ -104,7 +104,7 @@ Readers get the query's data from the cache. To get defined `data`, read the han ## 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. +Sometimes you only know the input during render, like a search term or a page a user clicks to. `experimental_waterfallHandle` builds the handle for such an input. It doesn't start the query: the component that reads the handle does, when it renders. That makes it a [request waterfall](./request-waterfalls.md), and the name keeps that visible. It is a plain function, not a hook, so you can call it anywhere. The devtools mark these queries with a `waterfall` badge. Pass it an input: @@ -174,6 +174,8 @@ const queryClient = new QueryClient({ [//]: # 'DehydratePending' +In development, `dehydrate` warns when a server render leaves out a pending query that a handle function started, because the browser would fetch it again. + ### Server `staleTime` Queries started on the server follow the same `staleTime` rules as in the browser. The data a server render sends along is as old as the server fetch, and when the browser reads it, a query with the default `staleTime: 0` already counts as stale. The component that reads it refetches on mount, so each request runs twice: once on the server and once in the browser. @@ -286,3 +288,11 @@ export function dehydrateUnsent(queryClient: QueryClient) { Every request the server handles shares the module. Once one request has sent a query, every later request for any user skips it, so their pages arrive without its data and fetch it again in the browser. The set also grows for as long as the server runs. Each query goes out once, with the state it has when `dehydrate` first includes it. If it changes later in the same request, the change doesn't reach the browser. + +## Devtools + +In development, the query devtools show how each handle's query started: + +- **Started by** shows the function that started the query and whether it ran on the server or in the browser. Queries that `hydrate` restores from a server render keep the server's record. +- A **waterfall** badge marks queries that a component first fetched by reading a handle from `experimental_waterfallHandle`. +- **Prefetched, never read** marks queries that a handle function started and no component has read since. That usually means a loader prefetches data the page no longer uses. diff --git a/packages/query-core/src/__tests__/hydration.test-d.tsx b/packages/query-core/src/__tests__/hydration.test-d.tsx index ae76ed7fda2..ae349d78df0 100644 --- a/packages/query-core/src/__tests__/hydration.test-d.tsx +++ b/packages/query-core/src/__tests__/hydration.test-d.tsx @@ -275,6 +275,7 @@ describe('hydration', () => { | 'promise' | 'meta' | 'queryType' + | 'experimental_start' >() }) }) diff --git a/packages/query-core/src/__tests__/queryStartInfo.test.tsx b/packages/query-core/src/__tests__/queryStartInfo.test.tsx new file mode 100644 index 00000000000..b0b5d9fd4ee --- /dev/null +++ b/packages/query-core/src/__tests__/queryStartInfo.test.tsx @@ -0,0 +1,208 @@ +import { describe, expect, it, vi } from 'vitest' +import { + clearOnDispose, + createKey, + fakeTimers, + sleep, +} from '@tanstack/query-test-utils' +import { + QueryClient, + QueryObserver, + dehydrate, + environmentManager, + experimental_fromHandle, + experimental_getQueryStartInfo, + experimental_prefetchHandle, + experimental_waterfallHandle, + hydrate, + isServer, + noop, +} from '..' +import type { DehydratedState } from '..' + +const serverEnvironment = (): Disposable => { + environmentManager.setIsServer(() => true) + return { + [Symbol.dispose]: () => { + environmentManager.setIsServer(() => isServer) + }, + } +} + +const getQuery = ( + queryClient: QueryClient, + queryKey: ReadonlyArray, +) => { + const query = queryClient.getQueryCache().find({ queryKey, exact: true }) + if (!query) { + throw new Error('no query') + } + return query +} + +const createPostQuery = () => { + const key = createKey() + return (input: { id: string }) => ({ + queryKey: [...key, input.id], + queryFn: () => sleep(10).then(() => input.id), + }) +} + +describe('query start info', () => { + it('should record how a query was started and when an observer reads it', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + const query = getQuery(queryClient, handle.queryKey) + + expect(experimental_getQueryStartInfo(query)).toStrictEqual({ + verb: 'prefetchHandle', + environment: 'client', + startedAt: Date.now(), + observed: false, + }) + + const unsubscribe = new QueryObserver( + queryClient, + experimental_fromHandle(postQuery, handle), + ).subscribe(noop) + + expect(experimental_getQueryStartInfo(query)?.observed).toBe(true) + unsubscribe() + }) + + it('should record the first fetch of a reader of an experimental_waterfallHandle handle', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_waterfallHandle(postQuery, { id: '1' }) + + expect(handle).toStrictEqual({ + queryKey: postQuery({ id: '1' }).queryKey, + input: { id: '1' }, + }) + expect(JSON.parse(JSON.stringify(handle))).toStrictEqual(handle) + + const startedAt = Date.now() + const observer = new QueryObserver( + queryClient, + experimental_fromHandle(postQuery, handle), + ) + const unsubscribe = observer.subscribe(noop) + const query = getQuery(queryClient, handle.queryKey) + + expect(experimental_getQueryStartInfo(query)).toStrictEqual({ + verb: 'waterfallHandle', + environment: 'client', + startedAt, + observed: true, + }) + + await vi.advanceTimersByTimeAsync(20) + void observer.refetch() + + expect(experimental_getQueryStartInfo(query)?.startedAt).toBe(startedAt) + unsubscribe() + }) + + it('should keep the record of a loader handle that experimental_waterfallHandle switches back to', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + const switched = experimental_waterfallHandle(postQuery, handle, () => ({ + id: '1', + })) + + const unsubscribe = new QueryObserver( + queryClient, + experimental_fromHandle(postQuery, switched), + ).subscribe(noop) + + expect( + experimental_getQueryStartInfo(getQuery(queryClient, handle.queryKey)) + ?.verb, + ).toBe('prefetchHandle') + unsubscribe() + }) + + it('should not record queries started by other APIs', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + + void queryClient.query(postQuery({ id: '1' })).catch(noop) + await vi.advanceTimersByTimeAsync(10) + + expect( + experimental_getQueryStartInfo( + getQuery(queryClient, postQuery({ id: '1' }).queryKey), + ), + ).toBeUndefined() + expect(dehydrate(queryClient).queries[0]).not.toHaveProperty( + 'experimental_start', + ) + }) + + it('should carry the start info from the server to the client in development', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + let dehydrated: DehydratedState + { + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + experimental_prefetchHandle(serverClient, postQuery, { id: '1' }) + dehydrated = dehydrate(serverClient, { + shouldDehydrateQuery: () => true, + }) + } + + hydrate(queryClient, dehydrated) + + expect( + experimental_getQueryStartInfo( + getQuery(queryClient, postQuery({ id: '1' }).queryKey), + ), + ).toStrictEqual({ + verb: 'prefetchHandle', + environment: 'server', + startedAt: Date.now(), + observed: false, + }) + }) + + it('should warn when a server render leaves out a pending query a handle started', () => { + using _timers = fakeTimers() + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + const warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + experimental_prefetchHandle(serverClient, createPostQuery(), { id: '1' }) + + dehydrate(serverClient) + + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[0]).toContain('experimental_prefetchHandle') + }) + + it('should not warn about pending queries started by other APIs or in the browser', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + const postQuery = createPostQuery() + experimental_prefetchHandle(queryClient, postQuery, { id: '1' }) + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + void serverClient.query(postQuery({ id: '2' })).catch(noop) + + dehydrate(queryClient) + dehydrate(serverClient) + + expect(warn).not.toHaveBeenCalled() + }) +}) diff --git a/packages/query-core/src/handles.ts b/packages/query-core/src/handles.ts index 5f3f9c5f1c4..80e0adf3b3c 100644 --- a/packages/query-core/src/handles.ts +++ b/packages/query-core/src/handles.ts @@ -1,7 +1,14 @@ +import { environmentManager } from './environmentManager' import { hashQueryKeyByOptions, noop } from './utils' +import type { QueryCache } from './queryCache' import type { QueryClient } from './queryClient' import type { Query } from './query' -import type { InferDataFromTag, QueryKey, QueryKeyHashFunction } from './types' +import type { + InferDataFromTag, + QueryFunctionContext, + QueryKey, + QueryKeyHashFunction, +} from './types' // TYPES @@ -121,6 +128,151 @@ export type InferQueryHandleData< ? FactoryData> : never +/** + * How a query was last started through the handle APIs, as shown by the devtools. + */ +export interface QueryStartInfo { + /** + * The API that started the query. + */ + verb: 'prefetchHandle' | 'ensureHandle' | 'fetchHandle' | 'waterfallHandle' + /** + * Whether it ran on the server or in the browser. + */ + environment: 'server' | 'client' + /** + * When it ran, in milliseconds since the epoch. + */ + startedAt: number + /** + * Whether an observer, like `useQuery`, has read the query since. + */ + observed: boolean +} + +// START INFO + +// A registered symbol, so devtools that bundle their own copy of this package still find it +const startInfoKey = Symbol.for('@tanstack/query-core/experimental_start') +const watchedCaches = new WeakSet() + +type QueryWithStartInfo = Query & { + [startInfoKey]?: QueryStartInfo +} + +/** + * Stores how a query was started on the query, hidden from enumeration. + * @param query - The query that started. + * @param info - How it started. + */ +function setStartInfo( + query: Query, + info: QueryStartInfo, +): void { + Object.defineProperty(query, startInfoKey, { + value: info, + configurable: true, + enumerable: false, + writable: true, + }) +} + +/** + * Marks recorded queries as read when an observer subscribes to them. + * @param queryCache - The cache to watch, once. + */ +function watchObservers(queryCache: QueryCache): void { + if (watchedCaches.has(queryCache)) { + return + } + watchedCaches.add(queryCache) + queryCache.subscribe((event) => { + if (event.type === 'observerAdded') { + const info = experimental_getQueryStartInfo(event.query) + if (info && !info.observed) { + setStartInfo(event.query, { ...info, observed: true }) + } + } + }) +} + +/** + * Records how a query was started, for the devtools. A query that an observer has read stays read. + * @internal + * @param queryCache - The cache the query belongs to. + * @param query - The query that started. + * @param info - How it started. + */ +export function recordQueryStart( + queryCache: QueryCache, + query: Query, + info: Omit & { observed?: boolean }, +): void { + watchObservers(queryCache) + const observed = + info.observed || + experimental_getQueryStartInfo(query)?.observed || + query.getObserversCount() > 0 + setStartInfo(query, { ...info, observed }) +} + +/** + * Returns how a query was last started through `experimental_prefetchHandle`, + * `experimental_ensureHandle` or `experimental_fetchHandle`, or first fetched by a reader of an + * `experimental_waterfallHandle` handle, if it was. A query hydrated from a server render in + * development keeps the server's record. + * @param query - The query to look up. + * @returns The start info, or `undefined` if the query wasn't started through those APIs. + */ +export function experimental_getQueryStartInfo( + query: Query, +): QueryStartInfo | undefined { + return (query as QueryWithStartInfo)[startInfoKey] +} + +const waterfallKey = Symbol.for('@tanstack/query-core/experimental_waterfall') + +/** + * Marks a handle from `experimental_waterfallHandle`, hidden from enumeration so the handle still + * equals and serializes as `{ queryKey, input }`. + * @param handle - The handle to mark. + * @returns The handle. + */ +function markWaterfall(handle: THandle): THandle { + Object.defineProperty(handle, waterfallKey, { value: true }) + return handle +} + +/** + * Wraps the `queryFn` of a waterfall handle's options so the query's first fetch is recorded with + * the verb `waterfallHandle`, since that fetch is the waterfall. + * @param options - Options a query factory returned for the handle's input. + * @returns The options, with the wrapped `queryFn`. + */ +function recordWaterfallStart( + options: QueryFactoryOptions & { queryFn?: unknown }, +): QueryFactoryOptions { + const queryFn = options.queryFn + if (typeof queryFn !== 'function') { + return options + } + return { + ...options, + queryFn: (context: QueryFunctionContext) => { + const queryCache = context.client.getQueryCache() + const query = getQuery(context.client, options) + if (query && !experimental_getQueryStartInfo(query)) { + recordQueryStart(queryCache, query, { + verb: 'waterfallHandle', + environment: environmentManager.isServer() ? 'server' : 'client', + startedAt: Date.now(), + }) + } + return queryFn(context) + }, + } as QueryFactoryOptions +} + // FUNCTIONS /** @@ -133,18 +285,30 @@ function isInfiniteOptions(options: QueryFactoryOptions): boolean { } /** - * Starts a factory's query without waiting. + * Starts a factory's query without waiting, and records the start for the devtools. * @param client - The client to start the query on. * @param options - Options a query factory returned. + * @param verb - The API that starts it. * @returns The promise of the query's data. */ function startQueryFromFactory( client: QueryClient, options: QueryFactoryOptions, + verb: QueryStartInfo['verb'], ): Promise { - return isInfiniteOptions(options) + const promise: Promise = isInfiniteOptions(options) ? client.infiniteQuery(options as never) : client.query(options) + const queryCache = client.getQueryCache() + const query = getQuery(client, options) + if (query) { + recordQueryStart(queryCache, query, { + verb, + environment: environmentManager.isServer() ? 'server' : 'client', + startedAt: Date.now(), + }) + } + return promise } /** @@ -201,7 +365,7 @@ export function experimental_prefetchHandle< ...input: QueryHandleArgs ): QueryHandle { const options = factory(...input) - void startQueryFromFactory(queryClient, options).catch(noop) + void startQueryFromFactory(queryClient, options, 'prefetchHandle').catch(noop) return toHandle(options, input[0]) as QueryHandle } @@ -228,7 +392,7 @@ export async function experimental_ensureHandle< ...input: QueryHandleArgs ): Promise> { const options = factory(...input) - const promise = startQueryFromFactory(queryClient, options) + const promise = startQueryFromFactory(queryClient, options, 'ensureHandle') if (getQuery(queryClient, options)?.state.data === undefined) { await promise } else { @@ -259,7 +423,7 @@ export async function experimental_fetchHandle< ...input: QueryHandleArgs ): Promise> { const options = factory(...input) - await startQueryFromFactory(queryClient, options) + await startQueryFromFactory(queryClient, options, 'fetchHandle') return toHandle(options, input[0]) as QueryHandle } @@ -325,9 +489,8 @@ export function experimental_waterfallHandle< ((input: QueryHandleInput) => QueryHandleInput)?, ] if (typeof switchInput !== 'function') { - return toHandle( - factory(inputOrHandle), - inputOrHandle, + return markWaterfall( + toHandle(factory(inputOrHandle), inputOrHandle), ) as QueryHandle } const previous = inputOrHandle as QueryHandle @@ -339,7 +502,7 @@ export function experimental_waterfallHandle< ) { return previous } - return toHandle(options, input) as QueryHandle + return markWaterfall(toHandle(options, input)) as QueryHandle } /** @@ -365,5 +528,9 @@ export function experimental_fromHandle( factory: TFactory, handle: QueryHandle>, ): FromHandleOptions { - return factory(handle.input) as FromHandleOptions + const options = + waterfallKey in handle + ? recordWaterfallStart(factory(handle.input)) + : factory(handle.input) + return options as FromHandleOptions } diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index 6f763d645ae..2dbe7fd6354 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -1,4 +1,6 @@ +import { experimental_getQueryStartInfo, recordQueryStart } from './handles' import { noop } from './utils' +import type { QueryStartInfo } from './handles' import type { DefaultError, MutationKey, @@ -92,6 +94,10 @@ interface DehydratedQuery { promise?: Promise meta?: QueryMeta queryType?: 'infinite' + /** + * In development, how the query was started through the handle APIs, for the devtools. + */ + experimental_start?: QueryStartInfo } /** @@ -197,9 +203,20 @@ export function dehydrateQuery( }), ...(query.meta && { meta: query.meta }), ...(query.queryType && { queryType: query.queryType }), + ...(process.env.NODE_ENV !== 'production' && startInfoOf(query)), } } +/** + * Reads how a query was started through the handle APIs, for `dehydrateQuery`. + * @param query - The query to dehydrate. + * @returns The `experimental_start` field, if the query has start info. + */ +function startInfoOf(query: Query) { + const start = experimental_getQueryStartInfo(query) + return start && { experimental_start: start } +} + /** * The default `shouldDehydrateMutation` predicate used by `dehydrate`. Only dehydrates mutations that are * currently paused (e.g. paused by `networkMode` while offline). @@ -273,15 +290,38 @@ export function dehydrate( const queries = client .getQueryCache() .getAll() - .flatMap((query) => - filterQuery(query) - ? [dehydrateQuery(query, serializeData, shouldRedactErrors)] - : [], - ) + .flatMap((query) => { + if (filterQuery(query)) { + return [dehydrateQuery(query, serializeData, shouldRedactErrors)] + } + if (process.env.NODE_ENV !== 'production') { + warnIfPendingHandleQueryIsLeftOut(query) + } + return [] + }) return { mutations, queries } } +/** + * Warns when a server render leaves out a pending query that a handle verb started, because a + * component that reads the handle in the browser would fetch it again. + * @param query - A query that `shouldDehydrateQuery` left out. + */ +function warnIfPendingHandleQueryIsLeftOut(query: Query): void { + const start = experimental_getQueryStartInfo(query) + if ( + start && + start.verb !== 'waterfallHandle' && + start.environment === 'server' && + query.state.status === 'pending' + ) { + console.warn( + `The query ${query.queryHash} was started with experimental_${start.verb} and is still pending, but shouldDehydrateQuery left it out, so its data won't reach the client. Include pending queries in shouldDehydrateQuery, or await the query with experimental_ensureHandle or experimental_fetchHandle before dehydrating.`, + ) + } +} + /** * Restores a `DehydratedState` (as produced by `dehydrate`) into a `QueryClient`'s cache, typically to seed the * client with data already fetched on the server. `mutations` and `queries` are each optional on `dehydratedState`. @@ -335,6 +375,7 @@ export function hydrate( promise, dehydratedAt, queryType, + experimental_start, }) => { const syncData = promise ? tryResolveSync(promise) : undefined const rawData = state.data === undefined ? syncData?.data : state.data @@ -410,6 +451,13 @@ export function hydrate( ) } + if (experimental_start && !experimental_getQueryStartInfo(query)) { + recordQueryStart(queryCache, query, { + ...experimental_start, + observed: false, + }) + } + if ( promise && // If the data was synchronously available, there is no need to set up diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index ab80de65190..0d283004d21 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -49,6 +49,7 @@ export { experimental_ensureHandle, experimental_fetchHandle, experimental_fromHandle, + experimental_getQueryStartInfo, experimental_prefetchHandle, experimental_waterfallHandle, } from './handles' @@ -57,6 +58,7 @@ export type { InferQueryHandleData as experimental_InferQueryHandleData, QueryHandle as experimental_QueryHandle, QueryHandleFactory as experimental_QueryHandleFactory, + QueryStartInfo as experimental_QueryStartInfo, } from './handles' // Types diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index c13e017377e..618f609f390 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -25,10 +25,12 @@ import { convertRemToPixels, displayValue, getMutationStatusColor, + getQueryStartInfo, getQueryStatusColor, getQueryStatusColorByLabel, getQueryStatusLabel, getSidedProp, + isPrefetchedButNeverRead, mutationSortFns, sortFns, } from './utils' @@ -1431,6 +1433,15 @@ const QueryRow: Component<{ query: Query }> = (props) => { (e) => e.query.queryHash === props.query.queryHash, ) + const isWaterfall = createSubscribeToQueryCacheBatcher( + (queryCache) => { + const query = queryCache().get(props.query.queryHash) + return !!query && getQueryStartInfo(query)?.verb === 'waterfallHandle' + }, + true, + (e) => e.query.queryHash === props.query.queryHash, + ) + const observers = createSubscribeToQueryCacheBatcher( (queryCache) => queryCache().get(props.query.queryHash)?.getObserversCount() ?? 0, @@ -1479,7 +1490,7 @@ const QueryRow: Component<{ query: Query }> = (props) => { styles().selectedQueryRow, 'tsqd-query-row', )} - aria-label={`Query key ${props.query.queryHash}${isDisabled() ? ', disabled' : ''}${isStatic() ? ', static' : ''}`} + aria-label={`Query key ${props.query.queryHash}${isDisabled() ? ', disabled' : ''}${isStatic() ? ', static' : ''}${isWaterfall() ? ', waterfall' : ''}`} >
= (props) => { static
+ + + ) @@ -1941,6 +1961,20 @@ const QueryDetails = () => { ?.getObserversCount() ?? 0, ) + const startInfo = createSubscribeToQueryCacheBatcher((queryCache) => { + const query = queryCache() + .getAll() + .find((q) => q.queryHash === selectedQueryHash()) + return query && getQueryStartInfo(query) + }) + + const neverRead = createSubscribeToQueryCacheBatcher((queryCache) => { + const query = queryCache() + .getAll() + .find((q) => q.queryHash === selectedQueryHash()) + return !!query && isPrefetchedButNeverRead(query) + }) + const color = createMemo(() => getQueryStatusColorByLabel(statusLabel())) const handleRefetch = () => { @@ -2060,6 +2094,31 @@ const QueryDetails = () => { {new Date(activeQueryState()!.dataUpdatedAt).toLocaleTimeString()} + + {(start) => ( +
+ Started by: + + {start().verb} ({start().environment}) + +
+ )} +
+ +
+ + Prefetched, never read: no component has read this query since{' '} + {startInfo()?.verb} started it. Check whether the loader still + needs to prefetch it. + +
+
{ return { css, glob: () => {}, setup: () => {} } }) +const serverEnvironment = (): Disposable => { + environmentManager.setIsServer(() => true) + return { + [Symbol.dispose]: () => { + environmentManager.setIsServer(() => isServer) + }, + } +} + +const clearOnDispose = (client: QueryClient): QueryClient & Disposable => + Object.assign(client, { + [Symbol.dispose]: () => { + client.clear() + }, + }) + describe('Devtools', () => { const storage: { [key: string]: string } = {} let queryClient: QueryClient @@ -723,6 +745,82 @@ describe('Devtools', () => { expect(rendered.getByLabelText(/, static/)).toBeInTheDocument() }) + it('should render a "waterfall" indicator for a query fetched by the reader of a waterfallHandle', () => { + const waterfallQuery = (input: { id: number }) => ({ + queryKey: ['waterfall-q', input.id], + queryFn: () => 'x', + }) + const handle = experimental_waterfallHandle(waterfallQuery, { id: 1 }) + const unsubscribe = new QueryObserver( + queryClient, + experimental_fromHandle(waterfallQuery, handle), + ).subscribe(noop) + const rendered = renderDevtools({ initialIsOpen: true }) + + expect(rendered.getByText('waterfall')).toBeInTheDocument() + expect(rendered.getByLabelText(/, waterfall/)).toBeInTheDocument() + unsubscribe() + }) + + it('should show how a query was started and that it was never read', async () => { + const handle = experimental_prefetchHandle( + queryClient, + (input: { id: number }) => ({ + queryKey: ['prefetched-q', input.id], + queryFn: () => 'x', + }), + { id: 1 }, + ) + await vi.advanceTimersByTimeAsync(0) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText('Query key ["prefetched-q",1]')) + + expect(rendered.getByText('prefetchHandle (client)')).toBeInTheDocument() + expect(rendered.getByRole('note')).toHaveTextContent( + 'Prefetched, never read', + ) + + const unsubscribe = new QueryObserver(queryClient, { + queryKey: handle.queryKey, + queryFn: () => 'x', + }).subscribe(noop) + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.queryByRole('note')).not.toBeInTheDocument() + unsubscribe() + }) + + it('should show where a hydrated query was started', async () => { + let dehydrated: DehydratedState + { + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + experimental_prefetchHandle(serverClient, () => ({ + queryKey: ['server-q'], + queryFn: () => 'x', + })) + await vi.advanceTimersByTimeAsync(0) + dehydrated = dehydrate(serverClient) + } + hydrate(queryClient, dehydrated) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText('Query key ["server-q"]')) + + expect(rendered.getByText('prefetchHandle (server)')).toBeInTheDocument() + }) + + it('should not show start info for queries started by other APIs', () => { + queryClient.setQueryData(['plain-q'], 'x') + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText('Query key ["plain-q"]')) + + expect(rendered.queryByText('Started by:')).not.toBeInTheDocument() + expect(rendered.queryByText('waterfall')).not.toBeInTheDocument() + }) + it('should render a query row when an object query key is mutated in place', () => { const filters = { page: 1 } queryClient.setQueryData(['mutable-key', filters], 'x') diff --git a/packages/query-devtools/src/utils.tsx b/packages/query-devtools/src/utils.tsx index 23b8df048b9..7f91feacd4c 100644 --- a/packages/query-devtools/src/utils.tsx +++ b/packages/query-devtools/src/utils.tsx @@ -1,6 +1,10 @@ import { serialize } from 'superjson' import { createSignal, onCleanup, onMount } from 'solid-js' -import type { Mutation, Query } from '@tanstack/query-core' +import type { + Mutation, + Query, + experimental_QueryStartInfo as QueryStartInfo, +} from '@tanstack/query-core' import type { DevtoolsPosition } from './contexts' /** @@ -21,6 +25,35 @@ export function getQueryStatusLabel(query: Query) { : 'fresh' } +/** + * Returns how a query was started through the experimental handle APIs, if it was. Reads the + * registered symbol that `@tanstack/query-core` stores it under, since the devtools bundle their + * own copy of that package. + * @param query - The query to look up. + * @returns The start info, or `undefined` for queries started by other APIs or older versions. + */ +export function getQueryStartInfo(query: Query): QueryStartInfo | undefined { + return (query as unknown as Record)[ + Symbol.for('@tanstack/query-core/experimental_start') + ] +} + +/** + * Whether a query was prefetched through a handle but no component has read it since, which + * usually means a loader prefetches data that the page no longer uses. + * @param query - The query to check. + * @returns `true` for a prefetched query that has never had an observer. + */ +export function isPrefetchedButNeverRead(query: Query): boolean { + const start = getQueryStartInfo(query) + return ( + !!start && + start.verb !== 'waterfallHandle' && + !start.observed && + query.getObserversCount() === 0 + ) +} + type QueryStatusLabel = 'fresh' | 'stale' | 'paused' | 'inactive' | 'fetching' /** diff --git a/packages/react-query/src/__tests__/waterfallHandle.test.tsx b/packages/react-query/src/__tests__/waterfallHandle.test.tsx index 0a349a844a3..296e5be1a23 100644 --- a/packages/react-query/src/__tests__/waterfallHandle.test.tsx +++ b/packages/react-query/src/__tests__/waterfallHandle.test.tsx @@ -12,6 +12,7 @@ import { QueryClient, experimental_fetchHandle, experimental_fromHandle, + experimental_getQueryStartInfo, experimental_prefetchHandle, experimental_waterfallHandle, queryOptions, @@ -61,6 +62,12 @@ describe('experimental_waterfallHandle', () => { await act(() => vi.advanceTimersByTimeAsync(11)) expect(rendered.getByText('data: page 1')).toBeInTheDocument() expect(queryFn).toHaveBeenCalledTimes(1) + const query = queryClient + .getQueryCache() + .find({ queryKey: postsQuery({ page: 1 }).queryKey, exact: true }) + expect(query && experimental_getQueryStartInfo(query)).toStrictEqual( + expect.objectContaining({ verb: 'waterfallHandle', observed: true }), + ) }) it("should keep the loader's handle until the new input maps to another query", async () => { From 1fbd5b119b6c776e9e85a4333a97f881f4dd5ab1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 01:02:47 +0000 Subject: [PATCH 2/5] test(query-core, query-devtools): unsubscribe and unmount the start info tests with 'using' Adds the 'subscribe' helper to @tanstack/query-test-utils, as in the hydrated rejection retry PR, and uses query-test-utils in query-devtools instead of a local 'clearOnDispose'. Console spies are held with 'using' too. Co-authored-by: alexander --- .../src/__tests__/queryStartInfo.test.tsx | 32 ++++++++------ packages/query-devtools/package.json | 1 + .../src/__tests__/Devtools.test.tsx | 44 ++++++++++--------- pnpm-lock.yaml | 3 ++ 4 files changed, 45 insertions(+), 35 deletions(-) diff --git a/packages/query-core/src/__tests__/queryStartInfo.test.tsx b/packages/query-core/src/__tests__/queryStartInfo.test.tsx index b0b5d9fd4ee..aade8912a5d 100644 --- a/packages/query-core/src/__tests__/queryStartInfo.test.tsx +++ b/packages/query-core/src/__tests__/queryStartInfo.test.tsx @@ -4,6 +4,7 @@ import { createKey, fakeTimers, sleep, + subscribe, } from '@tanstack/query-test-utils' import { QueryClient, @@ -65,13 +66,15 @@ describe('query start info', () => { observed: false, }) - const unsubscribe = new QueryObserver( - queryClient, - experimental_fromHandle(postQuery, handle), - ).subscribe(noop) + using _subscription = subscribe( + new QueryObserver( + queryClient, + experimental_fromHandle(postQuery, handle), + ), + noop, + ) expect(experimental_getQueryStartInfo(query)?.observed).toBe(true) - unsubscribe() }) it('should record the first fetch of a reader of an experimental_waterfallHandle handle', async () => { @@ -91,7 +94,7 @@ describe('query start info', () => { queryClient, experimental_fromHandle(postQuery, handle), ) - const unsubscribe = observer.subscribe(noop) + using _subscription = subscribe(observer, noop) const query = getQuery(queryClient, handle.queryKey) expect(experimental_getQueryStartInfo(query)).toStrictEqual({ @@ -105,7 +108,6 @@ describe('query start info', () => { void observer.refetch() expect(experimental_getQueryStartInfo(query)?.startedAt).toBe(startedAt) - unsubscribe() }) it('should keep the record of a loader handle that experimental_waterfallHandle switches back to', () => { @@ -119,16 +121,18 @@ describe('query start info', () => { id: '1', })) - const unsubscribe = new QueryObserver( - queryClient, - experimental_fromHandle(postQuery, switched), - ).subscribe(noop) + using _subscription = subscribe( + new QueryObserver( + queryClient, + experimental_fromHandle(postQuery, switched), + ), + noop, + ) expect( experimental_getQueryStartInfo(getQuery(queryClient, handle.queryKey)) ?.verb, ).toBe('prefetchHandle') - unsubscribe() }) it('should not record queries started by other APIs', async () => { @@ -181,7 +185,7 @@ describe('query start info', () => { using _timers = fakeTimers() using _server = serverEnvironment() using serverClient = clearOnDispose(new QueryClient()) - const warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) experimental_prefetchHandle(serverClient, createPostQuery(), { id: '1' }) dehydrate(serverClient) @@ -193,7 +197,7 @@ describe('query start info', () => { it('should not warn about pending queries started by other APIs or in the browser', () => { using _timers = fakeTimers() using queryClient = clearOnDispose(new QueryClient()) - const warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) const postQuery = createPostQuery() experimental_prefetchHandle(queryClient, postQuery, { id: '1' }) using _server = serverEnvironment() diff --git a/packages/query-devtools/package.json b/packages/query-devtools/package.json index f43bd19b9ad..9ab54764f40 100644 --- a/packages/query-devtools/package.json +++ b/packages/query-devtools/package.json @@ -75,6 +75,7 @@ }, "devDependencies": { "@solidjs/testing-library": "catalog:", + "@tanstack/query-test-utils": "workspace:*", "typescript": "catalog:", "unplugin-solid": "catalog:", "vite-plugin-solid": "catalog:" diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index 212c106f26c..8da4e3cf99b 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -15,6 +15,11 @@ import { } from '@tanstack/query-core' import { fireEvent, render, within } from '@solidjs/testing-library' import { createLocalStorage } from '@solid-primitives/storage' +import { + clearOnDispose, + subscribe, + unmountOnDispose, +} from '@tanstack/query-test-utils' import { DevtoolsOfflineProvider } from '../providers/DevtoolsOfflineProvider' import { DevtoolsSubscriptionsProvider } from '../providers/DevtoolsSubscriptionsProvider' import DevtoolsComponent from '../DevtoolsComponent' @@ -55,13 +60,6 @@ const serverEnvironment = (): Disposable => { } } -const clearOnDispose = (client: QueryClient): QueryClient & Disposable => - Object.assign(client, { - [Symbol.dispose]: () => { - client.clear() - }, - }) - describe('Devtools', () => { const storage: { [key: string]: string } = {} let queryClient: QueryClient @@ -751,15 +749,17 @@ describe('Devtools', () => { queryFn: () => 'x', }) const handle = experimental_waterfallHandle(waterfallQuery, { id: 1 }) - const unsubscribe = new QueryObserver( - queryClient, - experimental_fromHandle(waterfallQuery, handle), - ).subscribe(noop) - const rendered = renderDevtools({ initialIsOpen: true }) + using _subscription = subscribe( + new QueryObserver( + queryClient, + experimental_fromHandle(waterfallQuery, handle), + ), + noop, + ) + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) expect(rendered.getByText('waterfall')).toBeInTheDocument() expect(rendered.getByLabelText(/, waterfall/)).toBeInTheDocument() - unsubscribe() }) it('should show how a query was started and that it was never read', async () => { @@ -772,7 +772,7 @@ describe('Devtools', () => { { id: 1 }, ) await vi.advanceTimersByTimeAsync(0) - const rendered = renderDevtools({ initialIsOpen: true }) + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) fireEvent.click(rendered.getByLabelText('Query key ["prefetched-q",1]')) @@ -781,14 +781,16 @@ describe('Devtools', () => { 'Prefetched, never read', ) - const unsubscribe = new QueryObserver(queryClient, { - queryKey: handle.queryKey, - queryFn: () => 'x', - }).subscribe(noop) + using _subscription = subscribe( + new QueryObserver(queryClient, { + queryKey: handle.queryKey, + queryFn: () => 'x', + }), + noop, + ) await vi.advanceTimersByTimeAsync(0) expect(rendered.queryByRole('note')).not.toBeInTheDocument() - unsubscribe() }) it('should show where a hydrated query was started', async () => { @@ -804,7 +806,7 @@ describe('Devtools', () => { dehydrated = dehydrate(serverClient) } hydrate(queryClient, dehydrated) - const rendered = renderDevtools({ initialIsOpen: true }) + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) fireEvent.click(rendered.getByLabelText('Query key ["server-q"]')) @@ -813,7 +815,7 @@ describe('Devtools', () => { it('should not show start info for queries started by other APIs', () => { queryClient.setQueryData(['plain-q'], 'x') - const rendered = renderDevtools({ initialIsOpen: true }) + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) fireEvent.click(rendered.getByLabelText('Query key ["plain-q"]')) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f355ba6acaf..deeebf86cf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6587,6 +6587,9 @@ importers: '@solidjs/testing-library': specifier: 'catalog:' version: 0.8.10(@solidjs/router@0.15.4(solid-js@1.9.15))(solid-js@1.9.15) + '@tanstack/query-test-utils': + specifier: workspace:* + version: link:../query-test-utils typescript: specifier: 'catalog:' version: 6.0.3 From 3f7066ec4ec23451b5f28650313cb80c866c2675 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 14:11:52 +0000 Subject: [PATCH 3/5] docs(query-core, query-devtools): use plainer words in the start info copy Co-authored-by: alexander --- .changeset/query-handles-devtools.md | 2 +- .../src/__tests__/queryStartInfo.test.tsx | 6 +++--- packages/query-core/src/handles.ts | 18 +++++++++--------- packages/query-core/src/hydration.ts | 6 +++--- packages/query-devtools/src/Devtools.tsx | 2 +- .../src/__tests__/Devtools.test.tsx | 8 ++++---- packages/query-devtools/src/utils.tsx | 8 ++++---- 7 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.changeset/query-handles-devtools.md b/.changeset/query-handles-devtools.md index 9d31bb1fd44..355d71b43f8 100644 --- a/.changeset/query-handles-devtools.md +++ b/.changeset/query-handles-devtools.md @@ -3,4 +3,4 @@ '@tanstack/query-devtools': minor --- -feat: show how experimental query handles started their queries in the devtools. `experimental_getQueryStartInfo` returns which handle function started a query, where and when, and whether it has been read. The devtools show it, mark queries first fetched by a reader of an `experimental_waterfallHandle` handle and flag prefetched queries that were never read. In development, `dehydrate` warns when it leaves out a pending query that a handle function started on the server. +feat: show in the devtools how experimental query handles started their queries. `experimental_getQueryStartInfo` returns which handle function started a query, where and when, and whether anything has read it since. The devtools show this, mark queries that a reader of an `experimental_waterfallHandle` handle first fetched, and flag prefetched queries that nothing read. In development, `dehydrate` warns when it leaves out a pending query that a handle function started on the server. diff --git a/packages/query-core/src/__tests__/queryStartInfo.test.tsx b/packages/query-core/src/__tests__/queryStartInfo.test.tsx index aade8912a5d..73502dfff30 100644 --- a/packages/query-core/src/__tests__/queryStartInfo.test.tsx +++ b/packages/query-core/src/__tests__/queryStartInfo.test.tsx @@ -50,7 +50,7 @@ const createPostQuery = () => { } describe('query start info', () => { - it('should record how a query was started and when an observer reads it', () => { + it('should record how a query started and when an observer reads it', () => { using _timers = fakeTimers() using queryClient = clearOnDispose(new QueryClient()) const postQuery = createPostQuery() @@ -135,7 +135,7 @@ describe('query start info', () => { ).toBe('prefetchHandle') }) - it('should not record queries started by other APIs', async () => { + it('should not record queries that other APIs start', async () => { using _timers = fakeTimers() using queryClient = clearOnDispose(new QueryClient()) const postQuery = createPostQuery() @@ -194,7 +194,7 @@ describe('query start info', () => { expect(warn.mock.calls[0]?.[0]).toContain('experimental_prefetchHandle') }) - it('should not warn about pending queries started by other APIs or in the browser', () => { + it('should not warn about pending queries that other APIs or the browser started', () => { using _timers = fakeTimers() using queryClient = clearOnDispose(new QueryClient()) using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) diff --git a/packages/query-core/src/handles.ts b/packages/query-core/src/handles.ts index 80e0adf3b3c..98e233bcdd2 100644 --- a/packages/query-core/src/handles.ts +++ b/packages/query-core/src/handles.ts @@ -129,7 +129,7 @@ export type InferQueryHandleData< : never /** - * How a query was last started through the handle APIs, as shown by the devtools. + * How a handle API last started a query, for the devtools to show. */ export interface QueryStartInfo { /** @@ -161,7 +161,7 @@ type QueryWithStartInfo = Query & { } /** - * Stores how a query was started on the query, hidden from enumeration. + * Stores how a query started on the query itself, hidden from enumeration. * @param query - The query that started. * @param info - How it started. */ @@ -197,7 +197,7 @@ function watchObservers(queryCache: QueryCache): void { } /** - * Records how a query was started, for the devtools. A query that an observer has read stays read. + * Records how a query started, for the devtools. A query that an observer has read stays read. * @internal * @param queryCache - The cache the query belongs to. * @param query - The query that started. @@ -217,12 +217,12 @@ export function recordQueryStart( } /** - * Returns how a query was last started through `experimental_prefetchHandle`, - * `experimental_ensureHandle` or `experimental_fetchHandle`, or first fetched by a reader of an - * `experimental_waterfallHandle` handle, if it was. A query hydrated from a server render in - * development keeps the server's record. + * Returns how `experimental_prefetchHandle`, `experimental_ensureHandle` or + * `experimental_fetchHandle` last started a query, or whether a reader of an + * `experimental_waterfallHandle` handle first fetched it. In development, a query that `hydrate` + * restores from a server render keeps the server's record. * @param query - The query to look up. - * @returns The start info, or `undefined` if the query wasn't started through those APIs. + * @returns The start info, or `undefined` if none of those APIs started the query. */ export function experimental_getQueryStartInfo( query: Query, @@ -244,7 +244,7 @@ function markWaterfall(handle: THandle): THandle { } /** - * Wraps the `queryFn` of a waterfall handle's options so the query's first fetch is recorded with + * Wraps the `queryFn` of a waterfall handle's options so it records the query's first fetch with * the verb `waterfallHandle`, since that fetch is the waterfall. * @param options - Options a query factory returned for the handle's input. * @returns The options, with the wrapped `queryFn`. diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index 2dbe7fd6354..92102374a85 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -95,7 +95,7 @@ interface DehydratedQuery { meta?: QueryMeta queryType?: 'infinite' /** - * In development, how the query was started through the handle APIs, for the devtools. + * In development, how a handle API started the query, for the devtools. */ experimental_start?: QueryStartInfo } @@ -208,7 +208,7 @@ export function dehydrateQuery( } /** - * Reads how a query was started through the handle APIs, for `dehydrateQuery`. + * Reads how a handle API started a query, for `dehydrateQuery`. * @param query - The query to dehydrate. * @returns The `experimental_start` field, if the query has start info. */ @@ -317,7 +317,7 @@ function warnIfPendingHandleQueryIsLeftOut(query: Query): void { query.state.status === 'pending' ) { console.warn( - `The query ${query.queryHash} was started with experimental_${start.verb} and is still pending, but shouldDehydrateQuery left it out, so its data won't reach the client. Include pending queries in shouldDehydrateQuery, or await the query with experimental_ensureHandle or experimental_fetchHandle before dehydrating.`, + `experimental_${start.verb} started the query ${query.queryHash} and it is still pending, but shouldDehydrateQuery left it out, so its data won't reach the client. Include pending queries in shouldDehydrateQuery, or await the query with experimental_ensureHandle or experimental_fetchHandle before you dehydrate.`, ) } } diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index 618f609f390..1e07a16a2aa 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -1512,7 +1512,7 @@ const QueryRow: Component<{ query: Query }> = (props) => { diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index 8da4e3cf99b..1851a974d74 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -743,7 +743,7 @@ describe('Devtools', () => { expect(rendered.getByLabelText(/, static/)).toBeInTheDocument() }) - it('should render a "waterfall" indicator for a query fetched by the reader of a waterfallHandle', () => { + it('should render a "waterfall" indicator for a query that a waterfallHandle reader fetched', () => { const waterfallQuery = (input: { id: number }) => ({ queryKey: ['waterfall-q', input.id], queryFn: () => 'x', @@ -762,7 +762,7 @@ describe('Devtools', () => { expect(rendered.getByLabelText(/, waterfall/)).toBeInTheDocument() }) - it('should show how a query was started and that it was never read', async () => { + it('should show how a query started and that nothing read it', async () => { const handle = experimental_prefetchHandle( queryClient, (input: { id: number }) => ({ @@ -793,7 +793,7 @@ describe('Devtools', () => { expect(rendered.queryByRole('note')).not.toBeInTheDocument() }) - it('should show where a hydrated query was started', async () => { + it('should show where a hydrated query started', async () => { let dehydrated: DehydratedState { using _server = serverEnvironment() @@ -813,7 +813,7 @@ describe('Devtools', () => { expect(rendered.getByText('prefetchHandle (server)')).toBeInTheDocument() }) - it('should not show start info for queries started by other APIs', () => { + it('should not show start info for queries that other APIs start', () => { queryClient.setQueryData(['plain-q'], 'x') using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) diff --git a/packages/query-devtools/src/utils.tsx b/packages/query-devtools/src/utils.tsx index 7f91feacd4c..170bef5f58b 100644 --- a/packages/query-devtools/src/utils.tsx +++ b/packages/query-devtools/src/utils.tsx @@ -26,11 +26,11 @@ export function getQueryStatusLabel(query: Query) { } /** - * Returns how a query was started through the experimental handle APIs, if it was. Reads the + * Returns how an experimental handle API started a query, if one did. Reads the * registered symbol that `@tanstack/query-core` stores it under, since the devtools bundle their * own copy of that package. * @param query - The query to look up. - * @returns The start info, or `undefined` for queries started by other APIs or older versions. + * @returns The start info, or `undefined` for queries that other APIs or older versions started. */ export function getQueryStartInfo(query: Query): QueryStartInfo | undefined { return (query as unknown as Record)[ @@ -39,8 +39,8 @@ export function getQueryStartInfo(query: Query): QueryStartInfo | undefined { } /** - * Whether a query was prefetched through a handle but no component has read it since, which - * usually means a loader prefetches data that the page no longer uses. + * Whether a handle prefetched a query that no component has read since. That usually means a + * loader prefetches data the page no longer uses. * @param query - The query to check. * @returns `true` for a prefetched query that has never had an observer. */ From 8f594fed63da84b42e01913ce26a2ab933d0fefd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 14:35:35 +0000 Subject: [PATCH 4/5] fix(query-core, query-devtools): keep handles free of symbols and warn less Track waterfall handles in a WeakSet, so a Server Component can pass them on. dehydrate now warns once per query, only on the server, and not for queries an earlier dehydrate sent. A start on fresh data notifies the cache, so the devtools show it, and hydrate only restores start info in development. Co-authored-by: alexander --- .changeset/query-handles-devtools.md | 2 +- docs/framework/react/guides/query-handles.md | 6 +- .../src/__tests__/queryStartInfo.test.tsx | 107 +++++++++++++++++- packages/query-core/src/handles.ts | 30 ++--- packages/query-core/src/hydration.ts | 25 +++- .../src/__tests__/Devtools.test.tsx | 20 ++++ packages/query-devtools/src/utils.tsx | 10 +- 7 files changed, 172 insertions(+), 28 deletions(-) diff --git a/.changeset/query-handles-devtools.md b/.changeset/query-handles-devtools.md index 355d71b43f8..a630e3e019a 100644 --- a/.changeset/query-handles-devtools.md +++ b/.changeset/query-handles-devtools.md @@ -3,4 +3,4 @@ '@tanstack/query-devtools': minor --- -feat: show in the devtools how experimental query handles started their queries. `experimental_getQueryStartInfo` returns which handle function started a query, where and when, and whether anything has read it since. The devtools show this, mark queries that a reader of an `experimental_waterfallHandle` handle first fetched, and flag prefetched queries that nothing read. In development, `dehydrate` warns when it leaves out a pending query that a handle function started on the server. +Show in the devtools how experimental query handles started their queries. `experimental_getQueryStartInfo` returns which handle function started a query, where and when, and whether anything has read it since. The devtools show this, mark queries that a reader of an `experimental_waterfallHandle` handle first fetched, and flag prefetched queries that nothing read. In development, `dehydrate` warns when it leaves out a pending query that a handle function started on the server. diff --git a/docs/framework/react/guides/query-handles.md b/docs/framework/react/guides/query-handles.md index 05b80a01f46..75364f6aa00 100644 --- a/docs/framework/react/guides/query-handles.md +++ b/docs/framework/react/guides/query-handles.md @@ -174,7 +174,7 @@ const queryClient = new QueryClient({ [//]: # 'DehydratePending' -In development, `dehydrate` warns when a server render leaves out a pending query that a handle function started, because the browser would fetch it again. +In development, `dehydrate` warns once when a server render leaves out a pending query that a handle function started and no earlier `dehydrate` call sent, because the browser would fetch it again. ### Server `staleTime` @@ -291,8 +291,10 @@ Each query goes out once, with the state it has when `dehydrate` first includes ## Devtools -In development, the query devtools show how each handle's query started: +The query devtools show how each handle's query started: - **Started by** shows the function that started the query and whether it ran on the server or in the browser. Queries that `hydrate` restores from a server render keep the server's record. - A **waterfall** badge marks queries that a component first fetched by reading a handle from `experimental_waterfallHandle`. - **Prefetched, never read** marks queries that a handle function started and no component has read since. That usually means a loader prefetches data the page no longer uses. + +Records from a server render only reach the browser in development. diff --git a/packages/query-core/src/__tests__/queryStartInfo.test.tsx b/packages/query-core/src/__tests__/queryStartInfo.test.tsx index 73502dfff30..50bab345f6e 100644 --- a/packages/query-core/src/__tests__/queryStartInfo.test.tsx +++ b/packages/query-core/src/__tests__/queryStartInfo.test.tsx @@ -9,8 +9,10 @@ import { import { QueryClient, QueryObserver, + defaultShouldDehydrateQuery, dehydrate, environmentManager, + experimental_ensureHandle, experimental_fromHandle, experimental_getQueryStartInfo, experimental_prefetchHandle, @@ -19,7 +21,7 @@ import { isServer, noop, } from '..' -import type { DehydratedState } from '..' +import type { DehydratedState, QueryCacheNotifyEvent } from '..' const serverEnvironment = (): Disposable => { environmentManager.setIsServer(() => true) @@ -88,6 +90,8 @@ describe('query start info', () => { input: { id: '1' }, }) expect(JSON.parse(JSON.stringify(handle))).toStrictEqual(handle) + // React Server Components reject props with symbol properties + expect(Object.getOwnPropertySymbols(handle)).toHaveLength(0) const startedAt = Date.now() const observer = new QueryObserver( @@ -135,6 +139,49 @@ describe('query start info', () => { ).toBe('prefetchHandle') }) + it('should notify the cache when a handle restarts a query on fresh data', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose( + new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }), + ) + const postQuery = createPostQuery() + experimental_prefetchHandle(queryClient, postQuery, { id: '1' }) + await vi.advanceTimersByTimeAsync(10) + const query = getQuery(queryClient, postQuery({ id: '1' }).queryKey) + const listener = vi.fn<(event: QueryCacheNotifyEvent) => void>() + using _subscription = subscribe(queryClient.getQueryCache(), listener) + + await vi.advanceTimersByTimeAsync(5) + const handle = await experimental_ensureHandle(queryClient, postQuery, { + id: '1', + }) + + expect(queryClient.getQueryData(handle.queryKey)).toBe('1') + expect(experimental_getQueryStartInfo(query)).toMatchObject({ + verb: 'ensureHandle', + startedAt: Date.now(), + }) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ type: 'observerResultsUpdated', query }), + ) + }) + + it('should keep the start info when resetQueries resets the query', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + await vi.advanceTimersByTimeAsync(10) + + await queryClient.resetQueries({ queryKey: handle.queryKey }) + + expect( + experimental_getQueryStartInfo(getQuery(queryClient, handle.queryKey)), + ).toMatchObject({ verb: 'prefetchHandle' }) + }) + it('should not record queries that other APIs start', async () => { using _timers = fakeTimers() using queryClient = clearOnDispose(new QueryClient()) @@ -194,6 +241,64 @@ describe('query start info', () => { expect(warn.mock.calls[0]?.[0]).toContain('experimental_prefetchHandle') }) + it('should warn once per query', () => { + using _timers = fakeTimers() + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + experimental_prefetchHandle(serverClient, createPostQuery(), { id: '1' }) + + dehydrate(serverClient) + dehydrate(serverClient) + + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('should not warn about a pending query that an earlier dehydrate sent', () => { + using _timers = fakeTimers() + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + experimental_prefetchHandle(serverClient, createPostQuery(), { id: '1' }) + const sent = new Set() + const dehydrateUnsent = () => + dehydrate(serverClient, { + shouldDehydrateQuery: (query) => { + if (sent.has(query.queryHash)) return false + const include = + defaultShouldDehydrateQuery(query) || + query.state.status === 'pending' + if (include) sent.add(query.queryHash) + return include + }, + }) + + expect(dehydrateUnsent().queries).toHaveLength(1) + expect(dehydrateUnsent().queries).toHaveLength(0) + expect(warn).not.toHaveBeenCalled() + }) + + it('should not warn when the browser dehydrates a hydrated pending query', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + let dehydrated: DehydratedState + { + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + experimental_prefetchHandle(serverClient, createPostQuery(), { id: '1' }) + dehydrated = dehydrate(serverClient, { + shouldDehydrateQuery: () => true, + }) + } + + hydrate(queryClient, dehydrated) + // Like persistQueryClient saving the cache + dehydrate(queryClient) + + expect(warn).not.toHaveBeenCalled() + }) + it('should not warn about pending queries that other APIs or the browser started', () => { using _timers = fakeTimers() using queryClient = clearOnDispose(new QueryClient()) diff --git a/packages/query-core/src/handles.ts b/packages/query-core/src/handles.ts index 98e233bcdd2..8c709fd63e3 100644 --- a/packages/query-core/src/handles.ts +++ b/packages/query-core/src/handles.ts @@ -214,15 +214,17 @@ export function recordQueryStart( experimental_getQueryStartInfo(query)?.observed || query.getObserversCount() > 0 setStartInfo(query, { ...info, observed }) + // A start on fresh data doesn't fetch, so nothing else tells the devtools to read the record + queryCache.notify({ type: 'observerResultsUpdated', query }) } /** - * Returns how `experimental_prefetchHandle`, `experimental_ensureHandle` or - * `experimental_fetchHandle` last started a query, or whether a reader of an - * `experimental_waterfallHandle` handle first fetched it. In development, a query that `hydrate` - * restores from a server render keeps the server's record. + * Returns how a handle API started a query: the last `experimental_prefetchHandle`, + * `experimental_ensureHandle` or `experimental_fetchHandle` call, or the first fetch by a reader of + * an `experimental_waterfallHandle` handle. In development, a query that `hydrate` restores from a + * server render keeps the server's record. * @param query - The query to look up. - * @returns The start info, or `undefined` if none of those APIs started the query. + * @returns The start info, or `undefined` for queries that other APIs started. */ export function experimental_getQueryStartInfo( query: Query, @@ -230,16 +232,17 @@ export function experimental_getQueryStartInfo( return (query as QueryWithStartInfo)[startInfoKey] } -const waterfallKey = Symbol.for('@tanstack/query-core/experimental_waterfall') +// Kept off the handles, which must stay plain objects that a Server Component can pass on +const waterfallHandles = new WeakSet() /** - * Marks a handle from `experimental_waterfallHandle`, hidden from enumeration so the handle still - * equals and serializes as `{ queryKey, input }`. - * @param handle - The handle to mark. + * Remembers a handle from `experimental_waterfallHandle`, so `experimental_fromHandle` can record + * its reader's first fetch. + * @param handle - The handle to remember. * @returns The handle. */ function markWaterfall(handle: THandle): THandle { - Object.defineProperty(handle, waterfallKey, { value: true }) + waterfallHandles.add(handle) return handle } @@ -528,9 +531,8 @@ export function experimental_fromHandle( factory: TFactory, handle: QueryHandle>, ): FromHandleOptions { - const options = - waterfallKey in handle - ? recordWaterfallStart(factory(handle.input)) - : factory(handle.input) + const options = waterfallHandles.has(handle) + ? recordWaterfallStart(factory(handle.input)) + : factory(handle.input) return options as FromHandleOptions } diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index 92102374a85..1d48f616285 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -1,3 +1,4 @@ +import { environmentManager } from './environmentManager' import { experimental_getQueryStartInfo, recordQueryStart } from './handles' import { noop } from './utils' import type { QueryStartInfo } from './handles' @@ -292,6 +293,9 @@ export function dehydrate( .getAll() .flatMap((query) => { if (filterQuery(query)) { + if (process.env.NODE_ENV !== 'production') { + dehydratedQueries.add(query) + } return [dehydrateQuery(query, serializeData, shouldRedactErrors)] } if (process.env.NODE_ENV !== 'production') { @@ -303,9 +307,14 @@ export function dehydrate( return { mutations, queries } } +// In development, the queries some `dehydrate` call has sent, and those it has warned about +const dehydratedQueries = new WeakSet() +const warnedQueries = new WeakSet() + /** - * Warns when a server render leaves out a pending query that a handle verb started, because a - * component that reads the handle in the browser would fetch it again. + * Warns once when a server render leaves out a pending query that a handle verb started, because + * a component that reads the handle in the browser would fetch it again. Queries an earlier + * `dehydrate` call sent don't count, since the browser already gets them. * @param query - A query that `shouldDehydrateQuery` left out. */ function warnIfPendingHandleQueryIsLeftOut(query: Query): void { @@ -314,8 +323,12 @@ function warnIfPendingHandleQueryIsLeftOut(query: Query): void { start && start.verb !== 'waterfallHandle' && start.environment === 'server' && - query.state.status === 'pending' + environmentManager.isServer() && + query.state.status === 'pending' && + !dehydratedQueries.has(query) && + !warnedQueries.has(query) ) { + warnedQueries.add(query) console.warn( `experimental_${start.verb} started the query ${query.queryHash} and it is still pending, but shouldDehydrateQuery left it out, so its data won't reach the client. Include pending queries in shouldDehydrateQuery, or await the query with experimental_ensureHandle or experimental_fetchHandle before you dehydrate.`, ) @@ -451,7 +464,11 @@ export function hydrate( ) } - if (experimental_start && !experimental_getQueryStartInfo(query)) { + if ( + process.env.NODE_ENV !== 'production' && + experimental_start && + !experimental_getQueryStartInfo(query) + ) { recordQueryStart(queryCache, query, { ...experimental_start, observed: false, diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index 1851a974d74..3c10b9e2ae8 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -5,6 +5,7 @@ import { QueryObserver, dehydrate, environmentManager, + experimental_ensureHandle, experimental_fromHandle, experimental_prefetchHandle, experimental_waterfallHandle, @@ -793,6 +794,25 @@ describe('Devtools', () => { expect(rendered.queryByRole('note')).not.toBeInTheDocument() }) + it('should show a new start on fresh data, which fetches nothing', async () => { + const factory = () => ({ + queryKey: ['restarted-q'], + queryFn: () => 'x', + staleTime: Infinity, + }) + experimental_prefetchHandle(queryClient, factory) + await vi.advanceTimersByTimeAsync(0) + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) + + fireEvent.click(rendered.getByLabelText('Query key ["restarted-q"]')) + expect(rendered.getByText('prefetchHandle (client)')).toBeInTheDocument() + + await experimental_ensureHandle(queryClient, factory) + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.getByText('ensureHandle (client)')).toBeInTheDocument() + }) + it('should show where a hydrated query started', async () => { let dehydrated: DehydratedState { diff --git a/packages/query-devtools/src/utils.tsx b/packages/query-devtools/src/utils.tsx index 170bef5f58b..d5faf1fa4f3 100644 --- a/packages/query-devtools/src/utils.tsx +++ b/packages/query-devtools/src/utils.tsx @@ -1,5 +1,6 @@ import { serialize } from 'superjson' import { createSignal, onCleanup, onMount } from 'solid-js' +import { experimental_getQueryStartInfo } from '@tanstack/query-core' import type { Mutation, Query, @@ -26,16 +27,13 @@ export function getQueryStatusLabel(query: Query) { } /** - * Returns how an experimental handle API started a query, if one did. Reads the - * registered symbol that `@tanstack/query-core` stores it under, since the devtools bundle their - * own copy of that package. + * Returns how an experimental handle API started a query, if one did. query-core stores it under a + * registered symbol, so this copy of query-core reads it even when the app uses another copy. * @param query - The query to look up. * @returns The start info, or `undefined` for queries that other APIs or older versions started. */ export function getQueryStartInfo(query: Query): QueryStartInfo | undefined { - return (query as unknown as Record)[ - Symbol.for('@tanstack/query-core/experimental_start') - ] + return experimental_getQueryStartInfo(query) } /** From f639acc036a82d0fb165f5ffc97838f5d35fe706 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 14:55:23 +0000 Subject: [PATCH 5/5] feat(query-core): only record query start info in development Production builds add no cache listener and wrap no queryFn. Co-authored-by: alexander --- .changeset/query-handles-devtools.md | 2 +- docs/framework/react/guides/query-handles.md | 4 +- .../src/__tests__/queryStartInfo.test.tsx | 52 +++++++++++++++++++ packages/query-core/src/handles.ts | 31 ++++++----- 4 files changed, 74 insertions(+), 15 deletions(-) diff --git a/.changeset/query-handles-devtools.md b/.changeset/query-handles-devtools.md index a630e3e019a..2e118a671d4 100644 --- a/.changeset/query-handles-devtools.md +++ b/.changeset/query-handles-devtools.md @@ -3,4 +3,4 @@ '@tanstack/query-devtools': minor --- -Show in the devtools how experimental query handles started their queries. `experimental_getQueryStartInfo` returns which handle function started a query, where and when, and whether anything has read it since. The devtools show this, mark queries that a reader of an `experimental_waterfallHandle` handle first fetched, and flag prefetched queries that nothing read. In development, `dehydrate` warns when it leaves out a pending query that a handle function started on the server. +Show in the devtools how experimental query handles started their queries. In development, `experimental_getQueryStartInfo` returns which handle function started a query, where and when, and whether anything has read it since. Production builds record nothing. The devtools show this, mark queries that a reader of an `experimental_waterfallHandle` handle first fetched, and flag prefetched queries that nothing read. In development, `dehydrate` warns when it leaves out a pending query that a handle function started on the server. diff --git a/docs/framework/react/guides/query-handles.md b/docs/framework/react/guides/query-handles.md index 75364f6aa00..a338aa048ac 100644 --- a/docs/framework/react/guides/query-handles.md +++ b/docs/framework/react/guides/query-handles.md @@ -291,10 +291,10 @@ Each query goes out once, with the state it has when `dehydrate` first includes ## Devtools -The query devtools show how each handle's query started: +In development, the query devtools show how each handle's query started: - **Started by** shows the function that started the query and whether it ran on the server or in the browser. Queries that `hydrate` restores from a server render keep the server's record. - A **waterfall** badge marks queries that a component first fetched by reading a handle from `experimental_waterfallHandle`. - **Prefetched, never read** marks queries that a handle function started and no component has read since. That usually means a loader prefetches data the page no longer uses. -Records from a server render only reach the browser in development. +Core only records this in development, so the devtools show none of it in a production build. diff --git a/packages/query-core/src/__tests__/queryStartInfo.test.tsx b/packages/query-core/src/__tests__/queryStartInfo.test.tsx index 50bab345f6e..2859dd40116 100644 --- a/packages/query-core/src/__tests__/queryStartInfo.test.tsx +++ b/packages/query-core/src/__tests__/queryStartInfo.test.tsx @@ -32,6 +32,15 @@ const serverEnvironment = (): Disposable => { } } +const productionEnvironment = (): Disposable => { + vi.stubEnv('NODE_ENV', 'production') + return { + [Symbol.dispose]: () => { + vi.unstubAllEnvs() + }, + } +} + const getQuery = ( queryClient: QueryClient, queryKey: ReadonlyArray, @@ -182,6 +191,49 @@ describe('query start info', () => { ).toMatchObject({ verb: 'prefetchHandle' }) }) + it('should not record anything in production', async () => { + using _timers = fakeTimers() + using _production = productionEnvironment() + using queryClient = clearOnDispose(new QueryClient()) + const key = createKey() + const queryFn = () => sleep(10).then(() => 'data') + const postQuery = (input: { id: string }) => ({ + queryKey: [...key, input.id], + queryFn, + }) + + const prefetched = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + const waterfall = experimental_waterfallHandle(postQuery, { id: '2' }) + const options = experimental_fromHandle(postQuery, waterfall) + using _subscription = subscribe( + new QueryObserver(queryClient, options), + noop, + ) + await vi.advanceTimersByTimeAsync(10) + + expect(options.queryFn).toBe(queryFn) + expect( + experimental_getQueryStartInfo( + getQuery(queryClient, prefetched.queryKey), + ), + ).toBeUndefined() + expect( + experimental_getQueryStartInfo(getQuery(queryClient, waterfall.queryKey)), + ).toBeUndefined() + }) + + it('should not watch the cache in production', () => { + using _timers = fakeTimers() + using _production = productionEnvironment() + using queryClient = clearOnDispose(new QueryClient()) + + experimental_prefetchHandle(queryClient, createPostQuery(), { id: '1' }) + + expect(queryClient.getQueryCache().hasListeners()).toBe(false) + }) + it('should not record queries that other APIs start', async () => { using _timers = fakeTimers() using queryClient = clearOnDispose(new QueryClient()) diff --git a/packages/query-core/src/handles.ts b/packages/query-core/src/handles.ts index 8c709fd63e3..b75d56e09b4 100644 --- a/packages/query-core/src/handles.ts +++ b/packages/query-core/src/handles.ts @@ -129,7 +129,8 @@ export type InferQueryHandleData< : never /** - * How a handle API last started a query, for the devtools to show. + * How a handle API last started a query, for the devtools to show. Core only records it in + * development. */ export interface QueryStartInfo { /** @@ -198,6 +199,7 @@ function watchObservers(queryCache: QueryCache): void { /** * Records how a query started, for the devtools. A query that an observer has read stays read. + * Only call it in development. * @internal * @param queryCache - The cache the query belongs to. * @param query - The query that started. @@ -221,10 +223,10 @@ export function recordQueryStart( /** * Returns how a handle API started a query: the last `experimental_prefetchHandle`, * `experimental_ensureHandle` or `experimental_fetchHandle` call, or the first fetch by a reader of - * an `experimental_waterfallHandle` handle. In development, a query that `hydrate` restores from a - * server render keeps the server's record. + * an `experimental_waterfallHandle` handle. A query that `hydrate` restores from a server render + * keeps the server's record. Core only records start info in development. * @param query - The query to look up. - * @returns The start info, or `undefined` for queries that other APIs started. + * @returns The start info, or `undefined` in production and for queries that other APIs started. */ export function experimental_getQueryStartInfo( query: Query, @@ -242,7 +244,9 @@ const waterfallHandles = new WeakSet() * @returns The handle. */ function markWaterfall(handle: THandle): THandle { - waterfallHandles.add(handle) + if (process.env.NODE_ENV !== 'production') { + waterfallHandles.add(handle) + } return handle } @@ -288,7 +292,7 @@ function isInfiniteOptions(options: QueryFactoryOptions): boolean { } /** - * Starts a factory's query without waiting, and records the start for the devtools. + * Starts a factory's query without waiting. In development, it records the start for the devtools. * @param client - The client to start the query on. * @param options - Options a query factory returned. * @param verb - The API that starts it. @@ -302,10 +306,12 @@ function startQueryFromFactory( const promise: Promise = isInfiniteOptions(options) ? client.infiniteQuery(options as never) : client.query(options) - const queryCache = client.getQueryCache() - const query = getQuery(client, options) + const query = + process.env.NODE_ENV !== 'production' + ? getQuery(client, options) + : undefined if (query) { - recordQueryStart(queryCache, query, { + recordQueryStart(client.getQueryCache(), query, { verb, environment: environmentManager.isServer() ? 'server' : 'client', startedAt: Date.now(), @@ -531,8 +537,9 @@ export function experimental_fromHandle( factory: TFactory, handle: QueryHandle>, ): FromHandleOptions { - const options = waterfallHandles.has(handle) - ? recordWaterfallStart(factory(handle.input)) - : factory(handle.input) + const options = + process.env.NODE_ENV !== 'production' && waterfallHandles.has(handle) + ? recordWaterfallStart(factory(handle.input)) + : factory(handle.input) return options as FromHandleOptions }