Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/client-informed-prefetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-core': minor
---

feat: implement client-informed server prefetching
81 changes: 81 additions & 0 deletions packages/query-core/src/__tests__/clientCacheState.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
})
})
29 changes: 23 additions & 6 deletions packages/query-core/src/queryCache.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -220,4 +221,20 @@ export class QueryCache extends Subscribable<QueryCacheListener> {
})
})
}

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
}
}
89 changes: 73 additions & 16 deletions packages/query-core/src/queryClient.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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

Expand All @@ -65,6 +68,7 @@ export class QueryClient {
#queryDefaults: Map<string, QueryDefaults>
#mutationDefaults: Map<string, MutationDefaults>
#mountCount: number
#clientCacheState?: ClientCacheState
#unsubscribeFocus?: () => void
#unsubscribeOnline?: () => void

Expand All @@ -75,6 +79,7 @@ export class QueryClient {
this.#queryDefaults = new Map()
this.#mutationDefaults = new Map()
this.#mountCount = 0
this.#clientCacheState = config.clientCacheState
}

mount(): void {
Expand Down Expand Up @@ -377,7 +382,18 @@ export class QueryClient {
>(
options: FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
): Promise<void> {
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<
Expand Down Expand Up @@ -419,6 +435,17 @@ export class QueryClient {
TPageParam
>,
): Promise<void> {
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)
}

Expand Down Expand Up @@ -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
}
}
9 changes: 6 additions & 3 deletions packages/query-core/src/types.ts
Original file line number Diff line number Diff line change
@@ -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> = T extends undefined ? never : T

Expand Down Expand Up @@ -1352,10 +1352,13 @@ export type MutationObserverResult<
| MutationObserverErrorResult<TData, TError, TVariables, TOnMutateResult>
| MutationObserverSuccessResult<TData, TError, TVariables, TOnMutateResult>

export type ClientCacheState = Record<string, number>

export interface QueryClientConfig {
queryCache?: QueryCache
mutationCache?: MutationCache
defaultOptions?: DefaultOptions
clientCacheState?: ClientCacheState
}

export interface DefaultOptions<TError = DefaultError> {
Expand Down