diff --git a/.changeset/client-informed-prefetch.md b/.changeset/client-informed-prefetch.md new file mode 100644 index 0000000000..deb53d643f --- /dev/null +++ b/.changeset/client-informed-prefetch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': minor +--- + +feat: implement client-informed server prefetching diff --git a/packages/query-core/src/__tests__/clientCacheState.test.tsx b/packages/query-core/src/__tests__/clientCacheState.test.tsx new file mode 100644 index 0000000000..9cf4503b9e --- /dev/null +++ b/packages/query-core/src/__tests__/clientCacheState.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { QueryClient } from '../queryClient' +import { sleep } from '../utils' + +describe('Client Cache State', () => { + it('should prefetch query normally when no client state is provided', async () => { + const queryClient = new QueryClient() + const key = ['test'] + const serverData = 'server-data' + let fetchCount = 0 + + const queryFn = async () => { + fetchCount++ + await sleep(10) + return serverData + } + + await queryClient.prefetchQuery({ + queryKey: key, + queryFn, + staleTime: 5000, + }) + + expect(fetchCount).toBe(1) + }) + + it('should SKIP prefetch when client has fresh data', async () => { + // 1. Initialize QueryClient with clientCacheState indicating fresh data + const clientCacheState = { + '["test-optim"]': Date.now(), + } + + const queryClient = new QueryClient({ clientCacheState }) + + const key = ['test-optim'] + const serverData = 'server-data' + let fetchCount = 0 + + const queryFn = async () => { + fetchCount++ + await sleep(10) + return serverData + } + + await queryClient.prefetchQuery({ + queryKey: key, + queryFn, + staleTime: 5000, + }) + + expect(fetchCount).toBe(0) + }) + + it('should SKIP prefetchInfiniteQuery when client has fresh data', async () => { + const clientCacheState = { + '["test-infinite-optim"]': Date.now(), + } + + const queryClient = new QueryClient({ clientCacheState }) + + const key = ['test-infinite-optim'] + const serverData = 'server-data' + let fetchCount = 0 + + const queryFn = async () => { + fetchCount++ + await sleep(10) + return serverData + } + + await queryClient.prefetchInfiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + getNextPageParam: () => undefined, + staleTime: 5000, + }) + + expect(fetchCount).toBe(0) + }) +}) diff --git a/packages/query-core/src/queryCache.ts b/packages/query-core/src/queryCache.ts index dd7123eaac..bc51f7fb02 100644 --- a/packages/query-core/src/queryCache.ts +++ b/packages/query-core/src/queryCache.ts @@ -1,18 +1,19 @@ -import { hashQueryKeyByOptions, matchQuery } from './utils' -import { Query } from './query' import { notifyManager } from './notifyManager' -import { Subscribable } from './subscribable' -import type { QueryFilters } from './utils' import type { Action, QueryState } from './query' +import { Query } from './query' +import type { QueryClient } from './queryClient' +import type { QueryObserver } from './queryObserver' +import { Subscribable } from './subscribable' import type { + ClientCacheState, DefaultError, NotifyEvent, QueryKey, QueryOptions, WithRequired, } from './types' -import type { QueryClient } from './queryClient' -import type { QueryObserver } from './queryObserver' +import type { QueryFilters } from './utils' +import { hashQueryKeyByOptions, matchQuery } from './utils' // TYPES @@ -220,4 +221,20 @@ export class QueryCache extends Subscribable { }) }) } + + extractClientCacheState( + filter?: QueryFilters, + ): ClientCacheState { + const queries = this.findAll(filter) + const state: ClientCacheState = {} + + queries.forEach((query) => { + const { queryHash, state: queryState } = query + if (queryState.status === 'success') { + state[queryHash] = queryState.dataUpdatedAt + } + }) + + return state + } } diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 80cc36668a..38116675e8 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -1,20 +1,13 @@ -import { - functionalUpdate, - hashKey, - hashQueryKeyByOptions, - noop, - partialMatchKey, - resolveStaleTime, - skipToken, -} from './utils' -import { QueryCache } from './queryCache' -import { MutationCache } from './mutationCache' import { focusManager } from './focusManager' -import { onlineManager } from './onlineManager' -import { notifyManager } from './notifyManager' import { infiniteQueryBehavior } from './infiniteQueryBehavior' +import { MutationCache } from './mutationCache' +import { notifyManager } from './notifyManager' +import { onlineManager } from './onlineManager' +import type { QueryState } from './query' +import { QueryCache } from './queryCache' import type { CancelOptions, + ClientCacheState, DefaultError, DefaultOptions, DefaultedQueryObserverOptions, @@ -39,10 +32,20 @@ import type { RefetchOptions, RefetchQueryFilters, ResetOptions, - SetDataOptions, + SetDataOptions } from './types' -import type { QueryState } from './query' import type { MutationFilters, QueryFilters, Updater } from './utils' +import { + functionalUpdate, + hashKey, + hashQueryKeyByOptions, + noop, + partialMatchKey, + resolveStaleTime, + skipToken, + timeUntilStale, +} from './utils' + // TYPES @@ -65,6 +68,7 @@ export class QueryClient { #queryDefaults: Map #mutationDefaults: Map #mountCount: number + #clientCacheState?: ClientCacheState #unsubscribeFocus?: () => void #unsubscribeOnline?: () => void @@ -75,6 +79,7 @@ export class QueryClient { this.#queryDefaults = new Map() this.#mutationDefaults = new Map() this.#mountCount = 0 + this.#clientCacheState = config.clientCacheState } mount(): void { @@ -377,7 +382,18 @@ export class QueryClient { >( options: FetchQueryOptions, ): Promise { - return this.fetchQuery(options).then(noop).catch(noop) + const defaultedOptions = this.defaultQueryOptions(options) + + if ( + this.#shouldSkipPrefetch( + defaultedOptions.queryHash, + defaultedOptions.staleTime, + ) + ) { + return Promise.resolve() + } + + return this.fetchQuery(defaultedOptions).then(noop).catch(noop) } fetchInfiniteQuery< @@ -419,6 +435,17 @@ export class QueryClient { TPageParam >, ): Promise { + const defaultedOptions = this.defaultQueryOptions(options as any) + + if ( + this.#shouldSkipPrefetch( + defaultedOptions.queryHash, + defaultedOptions.staleTime, + ) + ) { + return Promise.resolve() + } + return this.fetchInfiniteQuery(options).then(noop).catch(noop) } @@ -645,4 +672,34 @@ export class QueryClient { this.#queryCache.clear() this.#mutationCache.clear() } + + #shouldSkipPrefetch( + queryHash: string, + staleTime: any, + ): boolean { + if (!this.#clientCacheState || staleTime === undefined) { + return false + } + + const clientUpdatedAt = this.#clientCacheState[queryHash] + + // If client doesn't have the data, we can't skip + if (clientUpdatedAt === undefined) { + return false + } + + // If staleTime is a function, we cannot determine freshness without building the Query instance. + // To keep this optimization cheap (avoiding Query instantiation), we skip the optimization for function staleTimes. + if (typeof staleTime === 'function') { + return false + } + + // If the query is static, it is always fresh + if (staleTime === 'static') { + return true + } + + // If the query is fresh, we can skip the prefetch + return timeUntilStale(clientUpdatedAt, staleTime) > 0 + } } diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 4f3f4caed2..c3baad3b51 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -1,13 +1,13 @@ /* istanbul ignore file */ -import type { QueryClient } from './queryClient' import type { DehydrateOptions, HydrateOptions } from './hydration' import type { MutationState } from './mutation' +import type { MutationCache } from './mutationCache' import type { FetchDirection, Query, QueryBehavior } from './query' +import type { QueryCache } from './queryCache' +import type { QueryClient } from './queryClient' import type { RetryDelayValue, RetryValue } from './retryer' import type { QueryFilters, QueryTypeFilter, SkipToken } from './utils' -import type { QueryCache } from './queryCache' -import type { MutationCache } from './mutationCache' export type NonUndefinedGuard = T extends undefined ? never : T @@ -1352,10 +1352,13 @@ export type MutationObserverResult< | MutationObserverErrorResult | MutationObserverSuccessResult +export type ClientCacheState = Record + export interface QueryClientConfig { queryCache?: QueryCache mutationCache?: MutationCache defaultOptions?: DefaultOptions + clientCacheState?: ClientCacheState } export interface DefaultOptions {