diff --git a/.changeset/query-handles-devtools.md b/.changeset/query-handles-devtools.md new file mode 100644 index 00000000000..2e118a671d4 --- /dev/null +++ b/.changeset/query-handles-devtools.md @@ -0,0 +1,6 @@ +--- +'@tanstack/query-core': minor +'@tanstack/query-devtools': minor +--- + +Show in the devtools how experimental query handles started their queries. In development, `experimental_getQueryStartInfo` returns which handle function started a query, where and when, and whether anything has read it since. Production builds record nothing. The devtools show this, mark queries that a reader of an `experimental_waterfallHandle` handle first fetched, and flag prefetched queries that nothing read. In development, `dehydrate` warns when it leaves out a pending query that a handle function started on the server. diff --git a/docs/framework/react/guides/query-handles.md b/docs/framework/react/guides/query-handles.md index 21d4323200c..a338aa048ac 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 once when a server render leaves out a pending query that a handle function started and no earlier `dehydrate` call sent, because the browser would fetch it again. + ### Server `staleTime` Queries started on the server follow the same `staleTime` rules as in the browser. The data a server render sends along is as old as the server fetch, and when the browser reads it, a query with the default `staleTime: 0` already counts as stale. The component that reads it refetches on mount, so each request runs twice: once on the server and once in the browser. @@ -286,3 +288,13 @@ 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. + +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__/hydration.test-d.tsx b/packages/query-core/src/__tests__/hydration.test-d.tsx index ae76ed7fda2..ae349d78df0 100644 --- a/packages/query-core/src/__tests__/hydration.test-d.tsx +++ b/packages/query-core/src/__tests__/hydration.test-d.tsx @@ -275,6 +275,7 @@ describe('hydration', () => { | 'promise' | 'meta' | 'queryType' + | 'experimental_start' >() }) }) diff --git a/packages/query-core/src/__tests__/queryStartInfo.test.tsx b/packages/query-core/src/__tests__/queryStartInfo.test.tsx new file mode 100644 index 00000000000..2859dd40116 --- /dev/null +++ b/packages/query-core/src/__tests__/queryStartInfo.test.tsx @@ -0,0 +1,369 @@ +import { describe, expect, it, vi } from 'vitest' +import { + clearOnDispose, + createKey, + fakeTimers, + sleep, + subscribe, +} from '@tanstack/query-test-utils' +import { + QueryClient, + QueryObserver, + defaultShouldDehydrateQuery, + dehydrate, + environmentManager, + experimental_ensureHandle, + experimental_fromHandle, + experimental_getQueryStartInfo, + experimental_prefetchHandle, + experimental_waterfallHandle, + hydrate, + isServer, + noop, +} from '..' +import type { DehydratedState, QueryCacheNotifyEvent } from '..' + +const serverEnvironment = (): Disposable => { + environmentManager.setIsServer(() => true) + return { + [Symbol.dispose]: () => { + environmentManager.setIsServer(() => isServer) + }, + } +} + +const productionEnvironment = (): Disposable => { + vi.stubEnv('NODE_ENV', 'production') + return { + [Symbol.dispose]: () => { + vi.unstubAllEnvs() + }, + } +} + +const getQuery = ( + queryClient: QueryClient, + queryKey: ReadonlyArray, +) => { + const query = queryClient.getQueryCache().find({ queryKey, exact: true }) + if (!query) { + throw new Error('no query') + } + return query +} + +const createPostQuery = () => { + const key = createKey() + return (input: { id: string }) => ({ + queryKey: [...key, input.id], + queryFn: () => sleep(10).then(() => input.id), + }) +} + +describe('query start info', () => { + it('should record how a query started and when an observer reads it', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + const query = getQuery(queryClient, handle.queryKey) + + expect(experimental_getQueryStartInfo(query)).toStrictEqual({ + verb: 'prefetchHandle', + environment: 'client', + startedAt: Date.now(), + observed: false, + }) + + using _subscription = subscribe( + new QueryObserver( + queryClient, + experimental_fromHandle(postQuery, handle), + ), + noop, + ) + + expect(experimental_getQueryStartInfo(query)?.observed).toBe(true) + }) + + it('should record the first fetch of a reader of an experimental_waterfallHandle handle', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_waterfallHandle(postQuery, { id: '1' }) + + expect(handle).toStrictEqual({ + queryKey: postQuery({ id: '1' }).queryKey, + input: { id: '1' }, + }) + expect(JSON.parse(JSON.stringify(handle))).toStrictEqual(handle) + // React Server Components reject props with symbol properties + expect(Object.getOwnPropertySymbols(handle)).toHaveLength(0) + + const startedAt = Date.now() + const observer = new QueryObserver( + queryClient, + experimental_fromHandle(postQuery, handle), + ) + using _subscription = subscribe(observer, noop) + const query = getQuery(queryClient, handle.queryKey) + + expect(experimental_getQueryStartInfo(query)).toStrictEqual({ + verb: 'waterfallHandle', + environment: 'client', + startedAt, + observed: true, + }) + + await vi.advanceTimersByTimeAsync(20) + void observer.refetch() + + expect(experimental_getQueryStartInfo(query)?.startedAt).toBe(startedAt) + }) + + it('should keep the record of a loader handle that experimental_waterfallHandle switches back to', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + const switched = experimental_waterfallHandle(postQuery, handle, () => ({ + id: '1', + })) + + using _subscription = subscribe( + new QueryObserver( + queryClient, + experimental_fromHandle(postQuery, switched), + ), + noop, + ) + + expect( + experimental_getQueryStartInfo(getQuery(queryClient, handle.queryKey)) + ?.verb, + ).toBe('prefetchHandle') + }) + + it('should notify the cache when a handle restarts a query on fresh data', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose( + new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }), + ) + const postQuery = createPostQuery() + experimental_prefetchHandle(queryClient, postQuery, { id: '1' }) + await vi.advanceTimersByTimeAsync(10) + const query = getQuery(queryClient, postQuery({ id: '1' }).queryKey) + const listener = vi.fn<(event: QueryCacheNotifyEvent) => void>() + using _subscription = subscribe(queryClient.getQueryCache(), listener) + + await vi.advanceTimersByTimeAsync(5) + const handle = await experimental_ensureHandle(queryClient, postQuery, { + id: '1', + }) + + expect(queryClient.getQueryData(handle.queryKey)).toBe('1') + expect(experimental_getQueryStartInfo(query)).toMatchObject({ + verb: 'ensureHandle', + startedAt: Date.now(), + }) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ type: 'observerResultsUpdated', query }), + ) + }) + + it('should keep the start info when resetQueries resets the query', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + const handle = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + await vi.advanceTimersByTimeAsync(10) + + await queryClient.resetQueries({ queryKey: handle.queryKey }) + + expect( + experimental_getQueryStartInfo(getQuery(queryClient, handle.queryKey)), + ).toMatchObject({ verb: 'prefetchHandle' }) + }) + + it('should not record anything in production', async () => { + using _timers = fakeTimers() + using _production = productionEnvironment() + using queryClient = clearOnDispose(new QueryClient()) + const key = createKey() + const queryFn = () => sleep(10).then(() => 'data') + const postQuery = (input: { id: string }) => ({ + queryKey: [...key, input.id], + queryFn, + }) + + const prefetched = experimental_prefetchHandle(queryClient, postQuery, { + id: '1', + }) + const waterfall = experimental_waterfallHandle(postQuery, { id: '2' }) + const options = experimental_fromHandle(postQuery, waterfall) + using _subscription = subscribe( + new QueryObserver(queryClient, options), + noop, + ) + await vi.advanceTimersByTimeAsync(10) + + expect(options.queryFn).toBe(queryFn) + expect( + experimental_getQueryStartInfo( + getQuery(queryClient, prefetched.queryKey), + ), + ).toBeUndefined() + expect( + experimental_getQueryStartInfo(getQuery(queryClient, waterfall.queryKey)), + ).toBeUndefined() + }) + + it('should not watch the cache in production', () => { + using _timers = fakeTimers() + using _production = productionEnvironment() + using queryClient = clearOnDispose(new QueryClient()) + + experimental_prefetchHandle(queryClient, createPostQuery(), { id: '1' }) + + expect(queryClient.getQueryCache().hasListeners()).toBe(false) + }) + + it('should not record queries that other APIs start', async () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + + void queryClient.query(postQuery({ id: '1' })).catch(noop) + await vi.advanceTimersByTimeAsync(10) + + expect( + experimental_getQueryStartInfo( + getQuery(queryClient, postQuery({ id: '1' }).queryKey), + ), + ).toBeUndefined() + expect(dehydrate(queryClient).queries[0]).not.toHaveProperty( + 'experimental_start', + ) + }) + + it('should carry the start info from the server to the client in development', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + const postQuery = createPostQuery() + let dehydrated: DehydratedState + { + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + experimental_prefetchHandle(serverClient, postQuery, { id: '1' }) + dehydrated = dehydrate(serverClient, { + shouldDehydrateQuery: () => true, + }) + } + + hydrate(queryClient, dehydrated) + + expect( + experimental_getQueryStartInfo( + getQuery(queryClient, postQuery({ id: '1' }).queryKey), + ), + ).toStrictEqual({ + verb: 'prefetchHandle', + environment: 'server', + startedAt: Date.now(), + observed: false, + }) + }) + + it('should warn when a server render leaves out a pending query a handle started', () => { + using _timers = fakeTimers() + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + experimental_prefetchHandle(serverClient, createPostQuery(), { id: '1' }) + + dehydrate(serverClient) + + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[0]).toContain('experimental_prefetchHandle') + }) + + it('should warn once per query', () => { + using _timers = fakeTimers() + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + experimental_prefetchHandle(serverClient, createPostQuery(), { id: '1' }) + + dehydrate(serverClient) + dehydrate(serverClient) + + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('should not warn about a pending query that an earlier dehydrate sent', () => { + using _timers = fakeTimers() + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + experimental_prefetchHandle(serverClient, createPostQuery(), { id: '1' }) + const sent = new Set() + const dehydrateUnsent = () => + dehydrate(serverClient, { + shouldDehydrateQuery: (query) => { + if (sent.has(query.queryHash)) return false + const include = + defaultShouldDehydrateQuery(query) || + query.state.status === 'pending' + if (include) sent.add(query.queryHash) + return include + }, + }) + + expect(dehydrateUnsent().queries).toHaveLength(1) + expect(dehydrateUnsent().queries).toHaveLength(0) + expect(warn).not.toHaveBeenCalled() + }) + + it('should not warn when the browser dehydrates a hydrated pending query', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + let dehydrated: DehydratedState + { + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + experimental_prefetchHandle(serverClient, createPostQuery(), { id: '1' }) + dehydrated = dehydrate(serverClient, { + shouldDehydrateQuery: () => true, + }) + } + + hydrate(queryClient, dehydrated) + // Like persistQueryClient saving the cache + dehydrate(queryClient) + + expect(warn).not.toHaveBeenCalled() + }) + + it('should not warn about pending queries that other APIs or the browser started', () => { + using _timers = fakeTimers() + using queryClient = clearOnDispose(new QueryClient()) + using warn = vi.spyOn(console, 'warn').mockReturnValue(undefined) + const postQuery = createPostQuery() + experimental_prefetchHandle(queryClient, postQuery, { id: '1' }) + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + void serverClient.query(postQuery({ id: '2' })).catch(noop) + + dehydrate(queryClient) + dehydrate(serverClient) + + expect(warn).not.toHaveBeenCalled() + }) +}) diff --git a/packages/query-core/src/handles.ts b/packages/query-core/src/handles.ts index 5f3f9c5f1c4..b75d56e09b4 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,158 @@ export type InferQueryHandleData< ? FactoryData> : never +/** + * How a handle API last started a query, for the devtools to show. Core only records it in + * development. + */ +export interface QueryStartInfo { + /** + * The API that started the query. + */ + verb: 'prefetchHandle' | 'ensureHandle' | 'fetchHandle' | 'waterfallHandle' + /** + * Whether it ran on the server or in the browser. + */ + environment: 'server' | 'client' + /** + * When it ran, in milliseconds since the epoch. + */ + startedAt: number + /** + * Whether an observer, like `useQuery`, has read the query since. + */ + observed: boolean +} + +// START INFO + +// A registered symbol, so devtools that bundle their own copy of this package still find it +const startInfoKey = Symbol.for('@tanstack/query-core/experimental_start') +const watchedCaches = new WeakSet() + +type QueryWithStartInfo = Query & { + [startInfoKey]?: QueryStartInfo +} + +/** + * Stores how a query started on the query itself, hidden from enumeration. + * @param query - The query that started. + * @param info - How it started. + */ +function setStartInfo( + query: Query, + info: QueryStartInfo, +): void { + Object.defineProperty(query, startInfoKey, { + value: info, + configurable: true, + enumerable: false, + writable: true, + }) +} + +/** + * Marks recorded queries as read when an observer subscribes to them. + * @param queryCache - The cache to watch, once. + */ +function watchObservers(queryCache: QueryCache): void { + if (watchedCaches.has(queryCache)) { + return + } + watchedCaches.add(queryCache) + queryCache.subscribe((event) => { + if (event.type === 'observerAdded') { + const info = experimental_getQueryStartInfo(event.query) + if (info && !info.observed) { + setStartInfo(event.query, { ...info, observed: true }) + } + } + }) +} + +/** + * Records how a query started, for the devtools. A query that an observer has read stays read. + * Only call it in development. + * @internal + * @param queryCache - The cache the query belongs to. + * @param query - The query that started. + * @param info - How it started. + */ +export function recordQueryStart( + queryCache: QueryCache, + query: Query, + info: Omit & { observed?: boolean }, +): void { + watchObservers(queryCache) + const observed = + info.observed || + experimental_getQueryStartInfo(query)?.observed || + query.getObserversCount() > 0 + setStartInfo(query, { ...info, observed }) + // A start on fresh data doesn't fetch, so nothing else tells the devtools to read the record + queryCache.notify({ type: 'observerResultsUpdated', query }) +} + +/** + * Returns how a handle API started a query: the last `experimental_prefetchHandle`, + * `experimental_ensureHandle` or `experimental_fetchHandle` call, or the first fetch by a reader of + * an `experimental_waterfallHandle` handle. A query that `hydrate` restores from a server render + * keeps the server's record. Core only records start info in development. + * @param query - The query to look up. + * @returns The start info, or `undefined` in production and for queries that other APIs started. + */ +export function experimental_getQueryStartInfo( + query: Query, +): QueryStartInfo | undefined { + return (query as QueryWithStartInfo)[startInfoKey] +} + +// Kept off the handles, which must stay plain objects that a Server Component can pass on +const waterfallHandles = new WeakSet() + +/** + * Remembers a handle from `experimental_waterfallHandle`, so `experimental_fromHandle` can record + * its reader's first fetch. + * @param handle - The handle to remember. + * @returns The handle. + */ +function markWaterfall(handle: THandle): THandle { + if (process.env.NODE_ENV !== 'production') { + waterfallHandles.add(handle) + } + return handle +} + +/** + * Wraps the `queryFn` of a waterfall handle's options so it records the query's first fetch with + * the verb `waterfallHandle`, since that fetch is the waterfall. + * @param options - Options a query factory returned for the handle's input. + * @returns The options, with the wrapped `queryFn`. + */ +function recordWaterfallStart( + options: QueryFactoryOptions & { queryFn?: unknown }, +): QueryFactoryOptions { + const queryFn = options.queryFn + if (typeof queryFn !== 'function') { + return options + } + return { + ...options, + queryFn: (context: QueryFunctionContext) => { + const queryCache = context.client.getQueryCache() + const query = getQuery(context.client, options) + if (query && !experimental_getQueryStartInfo(query)) { + recordQueryStart(queryCache, query, { + verb: 'waterfallHandle', + environment: environmentManager.isServer() ? 'server' : 'client', + startedAt: Date.now(), + }) + } + return queryFn(context) + }, + } as QueryFactoryOptions +} + // FUNCTIONS /** @@ -133,18 +292,32 @@ function isInfiniteOptions(options: QueryFactoryOptions): boolean { } /** - * Starts a factory's query without waiting. + * Starts a factory's query without waiting. In development, it records the start for the devtools. * @param client - The client to start the query on. * @param options - Options a query factory returned. + * @param verb - The API that starts it. * @returns The promise of the query's data. */ function startQueryFromFactory( client: QueryClient, options: QueryFactoryOptions, + verb: QueryStartInfo['verb'], ): Promise { - return isInfiniteOptions(options) + const promise: Promise = isInfiniteOptions(options) ? client.infiniteQuery(options as never) : client.query(options) + const query = + process.env.NODE_ENV !== 'production' + ? getQuery(client, options) + : undefined + if (query) { + recordQueryStart(client.getQueryCache(), query, { + verb, + environment: environmentManager.isServer() ? 'server' : 'client', + startedAt: Date.now(), + }) + } + return promise } /** @@ -201,7 +374,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 +401,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 +432,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 +498,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 +511,7 @@ export function experimental_waterfallHandle< ) { return previous } - return toHandle(options, input) as QueryHandle + return markWaterfall(toHandle(options, input)) as QueryHandle } /** @@ -365,5 +537,9 @@ export function experimental_fromHandle( factory: TFactory, handle: QueryHandle>, ): FromHandleOptions { - return factory(handle.input) as FromHandleOptions + const options = + process.env.NODE_ENV !== 'production' && waterfallHandles.has(handle) + ? recordWaterfallStart(factory(handle.input)) + : factory(handle.input) + return options as FromHandleOptions } diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index 6f763d645ae..1d48f616285 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -1,4 +1,7 @@ +import { environmentManager } from './environmentManager' +import { experimental_getQueryStartInfo, recordQueryStart } from './handles' import { noop } from './utils' +import type { QueryStartInfo } from './handles' import type { DefaultError, MutationKey, @@ -92,6 +95,10 @@ interface DehydratedQuery { promise?: Promise meta?: QueryMeta queryType?: 'infinite' + /** + * In development, how a handle API started the query, for the devtools. + */ + experimental_start?: QueryStartInfo } /** @@ -197,9 +204,20 @@ export function dehydrateQuery( }), ...(query.meta && { meta: query.meta }), ...(query.queryType && { queryType: query.queryType }), + ...(process.env.NODE_ENV !== 'production' && startInfoOf(query)), } } +/** + * Reads how a handle API started a query, for `dehydrateQuery`. + * @param query - The query to dehydrate. + * @returns The `experimental_start` field, if the query has start info. + */ +function startInfoOf(query: Query) { + const start = experimental_getQueryStartInfo(query) + return start && { experimental_start: start } +} + /** * The default `shouldDehydrateMutation` predicate used by `dehydrate`. Only dehydrates mutations that are * currently paused (e.g. paused by `networkMode` while offline). @@ -273,15 +291,50 @@ export function dehydrate( const queries = client .getQueryCache() .getAll() - .flatMap((query) => - filterQuery(query) - ? [dehydrateQuery(query, serializeData, shouldRedactErrors)] - : [], - ) + .flatMap((query) => { + if (filterQuery(query)) { + if (process.env.NODE_ENV !== 'production') { + dehydratedQueries.add(query) + } + return [dehydrateQuery(query, serializeData, shouldRedactErrors)] + } + if (process.env.NODE_ENV !== 'production') { + warnIfPendingHandleQueryIsLeftOut(query) + } + return [] + }) return { mutations, queries } } +// In development, the queries some `dehydrate` call has sent, and those it has warned about +const dehydratedQueries = new WeakSet() +const warnedQueries = new WeakSet() + +/** + * Warns once when a server render leaves out a pending query that a handle verb started, because + * a component that reads the handle in the browser would fetch it again. Queries an earlier + * `dehydrate` call sent don't count, since the browser already gets them. + * @param query - A query that `shouldDehydrateQuery` left out. + */ +function warnIfPendingHandleQueryIsLeftOut(query: Query): void { + const start = experimental_getQueryStartInfo(query) + if ( + start && + start.verb !== 'waterfallHandle' && + start.environment === 'server' && + environmentManager.isServer() && + query.state.status === 'pending' && + !dehydratedQueries.has(query) && + !warnedQueries.has(query) + ) { + warnedQueries.add(query) + console.warn( + `experimental_${start.verb} started the query ${query.queryHash} and it is still pending, but shouldDehydrateQuery left it out, so its data won't reach the client. Include pending queries in shouldDehydrateQuery, or await the query with experimental_ensureHandle or experimental_fetchHandle before you dehydrate.`, + ) + } +} + /** * Restores a `DehydratedState` (as produced by `dehydrate`) into a `QueryClient`'s cache, typically to seed the * client with data already fetched on the server. `mutations` and `queries` are each optional on `dehydratedState`. @@ -335,6 +388,7 @@ export function hydrate( promise, dehydratedAt, queryType, + experimental_start, }) => { const syncData = promise ? tryResolveSync(promise) : undefined const rawData = state.data === undefined ? syncData?.data : state.data @@ -410,6 +464,17 @@ export function hydrate( ) } + if ( + process.env.NODE_ENV !== 'production' && + experimental_start && + !experimental_getQueryStartInfo(query) + ) { + recordQueryStart(queryCache, query, { + ...experimental_start, + observed: false, + }) + } + if ( promise && // If the data was synchronously available, there is no need to set up diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 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/package.json b/packages/query-devtools/package.json index f43bd19b9ad..9ab54764f40 100644 --- a/packages/query-devtools/package.json +++ b/packages/query-devtools/package.json @@ -75,6 +75,7 @@ }, "devDependencies": { "@solidjs/testing-library": "catalog:", + "@tanstack/query-test-utils": "workspace:*", "typescript": "catalog:", "unplugin-solid": "catalog:", "vite-plugin-solid": "catalog:" diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index c13e017377e..1e07a16a2aa 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -25,10 +25,12 @@ import { convertRemToPixels, displayValue, getMutationStatusColor, + getQueryStartInfo, getQueryStatusColor, getQueryStatusColorByLabel, getQueryStatusLabel, getSidedProp, + isPrefetchedButNeverRead, mutationSortFns, sortFns, } from './utils' @@ -1431,6 +1433,15 @@ const QueryRow: Component<{ query: Query }> = (props) => { (e) => e.query.queryHash === props.query.queryHash, ) + const isWaterfall = createSubscribeToQueryCacheBatcher( + (queryCache) => { + const query = queryCache().get(props.query.queryHash) + return !!query && getQueryStartInfo(query)?.verb === 'waterfallHandle' + }, + true, + (e) => e.query.queryHash === props.query.queryHash, + ) + const observers = createSubscribeToQueryCacheBatcher( (queryCache) => queryCache().get(props.query.queryHash)?.getObserversCount() ?? 0, @@ -1479,7 +1490,7 @@ const QueryRow: Component<{ query: Query }> = (props) => { styles().selectedQueryRow, 'tsqd-query-row', )} - aria-label={`Query key ${props.query.queryHash}${isDisabled() ? ', disabled' : ''}${isStatic() ? ', static' : ''}`} + aria-label={`Query key ${props.query.queryHash}${isDisabled() ? ', disabled' : ''}${isStatic() ? ', static' : ''}${isWaterfall() ? ', waterfall' : ''}`} >
= (props) => { static
+ + + ) @@ -1941,6 +1961,20 @@ const QueryDetails = () => { ?.getObserversCount() ?? 0, ) + const startInfo = createSubscribeToQueryCacheBatcher((queryCache) => { + const query = queryCache() + .getAll() + .find((q) => q.queryHash === selectedQueryHash()) + return query && getQueryStartInfo(query) + }) + + const neverRead = createSubscribeToQueryCacheBatcher((queryCache) => { + const query = queryCache() + .getAll() + .find((q) => q.queryHash === selectedQueryHash()) + return !!query && isPrefetchedButNeverRead(query) + }) + const color = createMemo(() => getQueryStatusColorByLabel(statusLabel())) const handleRefetch = () => { @@ -2060,6 +2094,31 @@ const QueryDetails = () => { {new Date(activeQueryState()!.dataUpdatedAt).toLocaleTimeString()} + + {(start) => ( +
+ Started by: + + {start().verb} ({start().environment}) + +
+ )} +
+ +
+ + Prefetched, never read: no component has read this query since{' '} + {startInfo()?.verb} started it. Check whether the loader still + needs to prefetch it. + +
+
{ return { css, glob: () => {}, setup: () => {} } }) +const serverEnvironment = (): Disposable => { + environmentManager.setIsServer(() => true) + return { + [Symbol.dispose]: () => { + environmentManager.setIsServer(() => isServer) + }, + } +} + describe('Devtools', () => { const storage: { [key: string]: string } = {} let queryClient: QueryClient @@ -723,6 +744,105 @@ describe('Devtools', () => { expect(rendered.getByLabelText(/, static/)).toBeInTheDocument() }) + it('should render a "waterfall" indicator for a query that a waterfallHandle reader fetched', () => { + const waterfallQuery = (input: { id: number }) => ({ + queryKey: ['waterfall-q', input.id], + queryFn: () => 'x', + }) + const handle = experimental_waterfallHandle(waterfallQuery, { id: 1 }) + using _subscription = subscribe( + new QueryObserver( + queryClient, + experimental_fromHandle(waterfallQuery, handle), + ), + noop, + ) + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) + + expect(rendered.getByText('waterfall')).toBeInTheDocument() + expect(rendered.getByLabelText(/, waterfall/)).toBeInTheDocument() + }) + + it('should show how a query started and that nothing read it', async () => { + const handle = experimental_prefetchHandle( + queryClient, + (input: { id: number }) => ({ + queryKey: ['prefetched-q', input.id], + queryFn: () => 'x', + }), + { id: 1 }, + ) + await vi.advanceTimersByTimeAsync(0) + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) + + fireEvent.click(rendered.getByLabelText('Query key ["prefetched-q",1]')) + + expect(rendered.getByText('prefetchHandle (client)')).toBeInTheDocument() + expect(rendered.getByRole('note')).toHaveTextContent( + 'Prefetched, never read', + ) + + using _subscription = subscribe( + new QueryObserver(queryClient, { + queryKey: handle.queryKey, + queryFn: () => 'x', + }), + noop, + ) + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.queryByRole('note')).not.toBeInTheDocument() + }) + + it('should show a new start on fresh data, which fetches nothing', async () => { + const factory = () => ({ + queryKey: ['restarted-q'], + queryFn: () => 'x', + staleTime: Infinity, + }) + experimental_prefetchHandle(queryClient, factory) + await vi.advanceTimersByTimeAsync(0) + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) + + fireEvent.click(rendered.getByLabelText('Query key ["restarted-q"]')) + expect(rendered.getByText('prefetchHandle (client)')).toBeInTheDocument() + + await experimental_ensureHandle(queryClient, factory) + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.getByText('ensureHandle (client)')).toBeInTheDocument() + }) + + it('should show where a hydrated query started', async () => { + let dehydrated: DehydratedState + { + using _server = serverEnvironment() + using serverClient = clearOnDispose(new QueryClient()) + experimental_prefetchHandle(serverClient, () => ({ + queryKey: ['server-q'], + queryFn: () => 'x', + })) + await vi.advanceTimersByTimeAsync(0) + dehydrated = dehydrate(serverClient) + } + hydrate(queryClient, dehydrated) + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) + + fireEvent.click(rendered.getByLabelText('Query key ["server-q"]')) + + expect(rendered.getByText('prefetchHandle (server)')).toBeInTheDocument() + }) + + it('should not show start info for queries that other APIs start', () => { + queryClient.setQueryData(['plain-q'], 'x') + using rendered = unmountOnDispose(renderDevtools({ initialIsOpen: true })) + + fireEvent.click(rendered.getByLabelText('Query key ["plain-q"]')) + + expect(rendered.queryByText('Started by:')).not.toBeInTheDocument() + expect(rendered.queryByText('waterfall')).not.toBeInTheDocument() + }) + it('should render a query row when an object query key is mutated in place', () => { const filters = { page: 1 } queryClient.setQueryData(['mutable-key', filters], 'x') diff --git a/packages/query-devtools/src/utils.tsx b/packages/query-devtools/src/utils.tsx index 23b8df048b9..d5faf1fa4f3 100644 --- a/packages/query-devtools/src/utils.tsx +++ b/packages/query-devtools/src/utils.tsx @@ -1,6 +1,11 @@ import { serialize } from 'superjson' import { createSignal, onCleanup, onMount } from 'solid-js' -import type { Mutation, Query } from '@tanstack/query-core' +import { experimental_getQueryStartInfo } from '@tanstack/query-core' +import type { + Mutation, + Query, + experimental_QueryStartInfo as QueryStartInfo, +} from '@tanstack/query-core' import type { DevtoolsPosition } from './contexts' /** @@ -21,6 +26,32 @@ export function getQueryStatusLabel(query: Query) { : 'fresh' } +/** + * Returns how an experimental handle API started a query, if one did. query-core stores it under a + * registered symbol, so this copy of query-core reads it even when the app uses another copy. + * @param query - The query to look up. + * @returns The start info, or `undefined` for queries that other APIs or older versions started. + */ +export function getQueryStartInfo(query: Query): QueryStartInfo | undefined { + return experimental_getQueryStartInfo(query) +} + +/** + * Whether a handle prefetched a query that no component has read since. That usually means a + * loader prefetches data the page no longer uses. + * @param query - The query to check. + * @returns `true` for a prefetched query that has never had an observer. + */ +export function isPrefetchedButNeverRead(query: Query): boolean { + const start = getQueryStartInfo(query) + return ( + !!start && + start.verb !== 'waterfallHandle' && + !start.observed && + query.getObserversCount() === 0 + ) +} + type QueryStatusLabel = 'fresh' | 'stale' | 'paused' | 'inactive' | 'fetching' /** diff --git a/packages/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 () => { 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