From e1231d8e09d1cf0c99e85146194b568118ced616 Mon Sep 17 00:00:00 2001 From: Jinho Ayden Jeong <144667387+ayden94@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:36:18 +0900 Subject: [PATCH 1/5] feat(query-core): implement client-informed server prefetching --- packages/query-core/src/queryClient.ts | 55 +++++++++++++++++++------- packages/query-core/src/types.ts | 9 +++-- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 80cc36668a..587c3ee1c3 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, @@ -41,8 +34,18 @@ import type { ResetOptions, 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,27 @@ export class QueryClient { >( options: FetchQueryOptions, ): Promise { - return this.fetchQuery(options).then(noop).catch(noop) + const defaultedOptions = this.defaultQueryOptions(options) + + if (this.#clientCacheState && defaultedOptions.staleTime !== undefined) { + const queryHash = defaultedOptions.queryHash + const clientUpdatedAt = this.#clientCacheState[queryHash] + if ( + clientUpdatedAt !== undefined && + typeof defaultedOptions.staleTime !== 'function' + ) { + // If the query is static, it is always fresh + if (defaultedOptions.staleTime === 'static') { + return Promise.resolve() + } + // If the query is fresh, we can skip the prefetch + if (timeUntilStale(clientUpdatedAt, defaultedOptions.staleTime) > 0) { + return Promise.resolve() + } + } + } + + return this.fetchQuery(defaultedOptions).then(noop).catch(noop) } fetchInfiniteQuery< 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 { From dbab049f49991366de7164eeaddd86e697dd600d Mon Sep 17 00:00:00 2001 From: Jinho Ayden Jeong <144667387+ayden94@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:36:26 +0900 Subject: [PATCH 2/5] test(query-core): add verification for client cache state prefetching --- .../src/__tests__/clientCacheState.test.tsx | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 packages/query-core/src/__tests__/clientCacheState.test.tsx 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..34dd7026f2 --- /dev/null +++ b/packages/query-core/src/__tests__/clientCacheState.test.tsx @@ -0,0 +1,62 @@ +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) + }) + + // This test describes the DESIRED behavior, which is currently NOT implemented. + // It is expected to FAIL until we implement the changes. + it('should SKIP prefetch when client has fresh data (Simulated)', async () => { + // 1. Simulate hypothetical QueryClient with clientCacheState + // We haven't implemented the types yet, so we cast to any or expect it to be ignored for now. + const clientCacheState = { + '["test-optim"]': Date.now(), // Client has fresh data right now + } + + // @ts-ignore - API not implemented yet + 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, // 5 seconds stale time + }) + + // CURRENT BEHAVIOR: fetchCount is 1 (Server fetches anyway) + // DESIRED BEHAVIOR: fetchCount should be 0 (Server skips because client has it) + + // We expect this to equal 0 if our feature is working. + // For now, let's assert 0 and see it fail, proving the need for the feature. + expect(fetchCount).toBe(0) + }) +}) From abebeb449b8ae60af8b115131ff411168cb7c1b8 Mon Sep 17 00:00:00 2001 From: Jinho Ayden Jeong <144667387+ayden94@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:47:30 +0900 Subject: [PATCH 3/5] feat(query-core): optimize prefetch logic and support infinite queries --- .../src/__tests__/clientCacheState.test.tsx | 43 ++++++++---- packages/query-core/src/queryClient.ts | 66 ++++++++++++++----- 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/packages/query-core/src/__tests__/clientCacheState.test.tsx b/packages/query-core/src/__tests__/clientCacheState.test.tsx index 34dd7026f2..9cf4503b9e 100644 --- a/packages/query-core/src/__tests__/clientCacheState.test.tsx +++ b/packages/query-core/src/__tests__/clientCacheState.test.tsx @@ -24,16 +24,12 @@ describe('Client Cache State', () => { expect(fetchCount).toBe(1) }) - // This test describes the DESIRED behavior, which is currently NOT implemented. - // It is expected to FAIL until we implement the changes. - it('should SKIP prefetch when client has fresh data (Simulated)', async () => { - // 1. Simulate hypothetical QueryClient with clientCacheState - // We haven't implemented the types yet, so we cast to any or expect it to be ignored for now. + it('should SKIP prefetch when client has fresh data', async () => { + // 1. Initialize QueryClient with clientCacheState indicating fresh data const clientCacheState = { - '["test-optim"]': Date.now(), // Client has fresh data right now + '["test-optim"]': Date.now(), } - // @ts-ignore - API not implemented yet const queryClient = new QueryClient({ clientCacheState }) const key = ['test-optim'] @@ -49,14 +45,37 @@ describe('Client Cache State', () => { await queryClient.prefetchQuery({ queryKey: key, queryFn, - staleTime: 5000, // 5 seconds stale time + staleTime: 5000, }) - // CURRENT BEHAVIOR: fetchCount is 1 (Server fetches anyway) - // DESIRED BEHAVIOR: fetchCount should be 0 (Server skips because client has it) + expect(fetchCount).toBe(0) + }) + + it('should SKIP prefetchInfiniteQuery when client has fresh data', async () => { + const clientCacheState = { + '["test-infinite-optim"]': Date.now(), + } - // We expect this to equal 0 if our feature is working. - // For now, let's assert 0 and see it fail, proving the need for the feature. + 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/queryClient.ts b/packages/query-core/src/queryClient.ts index 587c3ee1c3..38116675e8 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -32,7 +32,7 @@ import type { RefetchOptions, RefetchQueryFilters, ResetOptions, - SetDataOptions, + SetDataOptions } from './types' import type { MutationFilters, QueryFilters, Updater } from './utils' import { @@ -384,22 +384,13 @@ export class QueryClient { ): Promise { const defaultedOptions = this.defaultQueryOptions(options) - if (this.#clientCacheState && defaultedOptions.staleTime !== undefined) { - const queryHash = defaultedOptions.queryHash - const clientUpdatedAt = this.#clientCacheState[queryHash] - if ( - clientUpdatedAt !== undefined && - typeof defaultedOptions.staleTime !== 'function' - ) { - // If the query is static, it is always fresh - if (defaultedOptions.staleTime === 'static') { - return Promise.resolve() - } - // If the query is fresh, we can skip the prefetch - if (timeUntilStale(clientUpdatedAt, defaultedOptions.staleTime) > 0) { - return Promise.resolve() - } - } + if ( + this.#shouldSkipPrefetch( + defaultedOptions.queryHash, + defaultedOptions.staleTime, + ) + ) { + return Promise.resolve() } return this.fetchQuery(defaultedOptions).then(noop).catch(noop) @@ -444,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) } @@ -670,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 + } } From ad34eca619c185078712510579387bbccbb3967d Mon Sep 17 00:00:00 2001 From: Jinho Ayden Jeong <144667387+ayden94@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:28:00 +0900 Subject: [PATCH 4/5] chore: add changeset for client-informed prefetching --- .changeset/client-informed-prefetch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/client-informed-prefetch.md 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 From 49be237c88c2fb7ae886d393c367913fb9805d7c Mon Sep 17 00:00:00 2001 From: Jinho Ayden Jeong <144667387+ayden94@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:28:18 +0900 Subject: [PATCH 5/5] feat(query-core): add extractClientCacheState helper to QueryCache --- packages/query-core/src/queryCache.ts | 29 +++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) 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 + } }