Skip to content

Commit b475d21

Browse files
authored
feat(core): staleTime: 'static' (#9139)
* feat(core): StaleTime.Static * fix: consider StaleTime.Static for invalidation previously, it didn't matter if we included `stale: true` or not in the filters for refetch, because marking things with query.invalidate() would set them all to stale anyway. Now, queries with StaleTime.Static will be marked as `invalidated`, but still shouldn't be refetched. The `isStale` filter logic accounts for that, so we have to include it in the filters * fix: isStale order check check for observers first because they contain the source of truth calculated with `isStaleByTime`, and it also takes enabled into account * fix: isStaleByTime logic we have to check for undefined data first, because queries without data are really always stale; then, the next check must be against StaleTime.Static, because queries that are marked as invalidated are still not stale, even if they are static. * tests * fix: type issue in react * fix: make sure invalidation _always_ only refetches stale queries this ensures we never refetch Static queries * fix: never refetch static queries * fix: make sure we don't refetchOn... for StaleTime.Static even when 'always' is set * docs: StaleTime.Static * ref: switch to 'static' string * docs
1 parent 34eedd6 commit b475d21

File tree

11 files changed

+186
-29
lines changed

11 files changed

+186
-29
lines changed

docs/framework/react/guides/important-defaults.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@ Out of the box, TanStack Query is configured with **aggressive but sane** defaul
99

1010
> To change this behavior, you can configure your queries both globally and per-query using the `staleTime` option. Specifying a longer `staleTime` means queries will not refetch their data as often
1111
12+
- A Query that has a `staleTime` set is considered **fresh** until that `staleTime` has elapsed.
13+
14+
- set `staleTime` to e.g. `2 * 60 * 1000` to make sure data is read from the cache, without triggering any kinds of refetches, for 2 minutes, or until the Query is [invalidated manually](./query-invalidation.md).
15+
- set `staleTime` to `Infinity` to never trigger a refetch until the Query is [invalidated manually](./query-invalidation.md).
16+
- set `staleTime` to `'static'` to **never** trigger a refetch, even if the Query is [invalidated manually](./query-invalidation.md).
17+
1218
- Stale queries are refetched automatically in the background when:
1319
- New instances of the query mount
1420
- The window is refocused
1521
- The network is reconnected
16-
- The query is optionally configured with a refetch interval
1722

18-
> To change this functionality, you can use options like `refetchOnMount`, `refetchOnWindowFocus`, `refetchOnReconnect` and `refetchInterval`.
23+
> Setting `staleTime` is the recommended way to avoid excessive refetches, but you can also customize the points in time for refetches by setting options like `refetchOnMount`, `refetchOnWindowFocus` and `refetchOnReconnect`.
24+
25+
- Queries can optionally be configured with a `refetchInterval` to trigger refetches periodically, which is independent of the `staleTime` setting.
1926

2027
- Query results that have no more active instances of `useQuery`, `useInfiniteQuery` or query observers are labeled as "inactive" and remain in the cache in case they are used again at a later time.
2128
- By default, "inactive" queries are garbage collected after **5 minutes**.

docs/framework/react/reference/useQuery.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,13 @@ const {
9090
- This function receives a `retryAttempt` integer and the actual Error and returns the delay to apply before the next attempt in milliseconds.
9191
- A function like `attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000)` applies exponential backoff.
9292
- A function like `attempt => attempt * 1000` applies linear backoff.
93-
- `staleTime: number | ((query: Query) => number)`
93+
- `staleTime: number | 'static' ((query: Query) => number | 'static')`
9494
- Optional
9595
- Defaults to `0`
9696
- The time in milliseconds after which data is considered stale. This value only applies to the hook it is defined on.
97-
- If set to `Infinity`, the data will never be considered stale
97+
- If set to `Infinity`, the data will not be considered stale unless manually invalidated
9898
- If set to a function, the function will be executed with the query to compute a `staleTime`.
99+
- If set to `'static'`, the data will never be considered stale
99100
- `gcTime: number | Infinity`
100101
- Defaults to `5 * 60 * 1000` (5 minutes) or `Infinity` during SSR
101102
- The time in milliseconds that unused/inactive cache data remains in memory. When a query's cache becomes unused or inactive, that cache data will be garbage collected after this duration. When different garbage collection times are specified, the longest one will be used.
@@ -116,21 +117,21 @@ const {
116117
- Defaults to `true`
117118
- If set to `true`, the query will refetch on mount if the data is stale.
118119
- If set to `false`, the query will not refetch on mount.
119-
- If set to `"always"`, the query will always refetch on mount.
120+
- If set to `"always"`, the query will always refetch on mount (except when `staleTime: 'static'` is used).
120121
- If set to a function, the function will be executed with the query to compute the value
121122
- `refetchOnWindowFocus: boolean | "always" | ((query: Query) => boolean | "always")`
122123
- Optional
123124
- Defaults to `true`
124125
- If set to `true`, the query will refetch on window focus if the data is stale.
125126
- If set to `false`, the query will not refetch on window focus.
126-
- If set to `"always"`, the query will always refetch on window focus.
127+
- If set to `"always"`, the query will always refetch on window focus (except when `staleTime: 'static'` is used).
127128
- If set to a function, the function will be executed with the query to compute the value
128129
- `refetchOnReconnect: boolean | "always" | ((query: Query) => boolean | "always")`
129130
- Optional
130131
- Defaults to `true`
131132
- If set to `true`, the query will refetch on reconnect if the data is stale.
132133
- If set to `false`, the query will not refetch on reconnect.
133-
- If set to `"always"`, the query will always refetch on reconnect.
134+
- If set to `"always"`, the query will always refetch on reconnect (except when `staleTime: 'static'` is used).
134135
- If set to a function, the function will be executed with the query to compute the value
135136
- `notifyOnChangeProps: string[] | "all" | (() => string[] | "all" | undefined)`
136137
- Optional

docs/reference/QueryClient.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ The `invalidateQueries` method can be used to invalidate and refetch single or m
321321

322322
- If you **do not want active queries to refetch**, and simply be marked as invalid, you can use the `refetchType: 'none'` option.
323323
- If you **want inactive queries to refetch** as well, use the `refetchType: 'all'` option
324+
- For refetching, [queryClient.refetchQueries](#queryclientrefetchqueries) is called.
324325

325326
```tsx
326327
await queryClient.invalidateQueries(
@@ -390,6 +391,11 @@ await queryClient.refetchQueries({
390391

391392
This function returns a promise that will resolve when all of the queries are done being refetched. By default, it **will not** throw an error if any of those queries refetches fail, but this can be configured by setting the `throwOnError` option to `true`
392393

394+
**Notes**
395+
396+
- Queries that are "disabled" because they only have disabled Observers will never be refetched.
397+
- Queries that are "static" because they only have Observers with a Static StaleTime will never be refetched.
398+
393399
## `queryClient.cancelQueries`
394400

395401
The `cancelQueries` method can be used to cancel outgoing queries based on their query keys or any other functionally accessible property/state of the query.

packages/query-core/src/__tests__/queryClient.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,35 @@ describe('queryClient', () => {
661661
expect(second).toBe(first)
662662
})
663663

664+
test('should read from cache with static staleTime even if invalidated', async () => {
665+
const key = queryKey()
666+
667+
const fetchFn = vi.fn(() => Promise.resolve({ data: 'data' }))
668+
const first = await queryClient.fetchQuery({
669+
queryKey: key,
670+
queryFn: fetchFn,
671+
staleTime: 'static',
672+
})
673+
674+
expect(first.data).toBe('data')
675+
expect(fetchFn).toHaveBeenCalledTimes(1)
676+
677+
await queryClient.invalidateQueries({
678+
queryKey: key,
679+
refetchType: 'none',
680+
})
681+
682+
const second = await queryClient.fetchQuery({
683+
queryKey: key,
684+
queryFn: fetchFn,
685+
staleTime: 'static',
686+
})
687+
688+
expect(fetchFn).toHaveBeenCalledTimes(1)
689+
690+
expect(second).toBe(first)
691+
})
692+
664693
test('should be able to fetch when garbage collection time is set to 0 and then be removed', async () => {
665694
const key1 = queryKey()
666695
const promise = queryClient.fetchQuery({
@@ -1323,6 +1352,25 @@ describe('queryClient', () => {
13231352
expect(queryFn1).toHaveBeenCalledTimes(2)
13241353
onlineMock.mockRestore()
13251354
})
1355+
1356+
test('should not refetch static queries', async () => {
1357+
const key = queryKey()
1358+
const queryFn = vi.fn(() => 'data1')
1359+
await queryClient.fetchQuery({ queryKey: key, queryFn: queryFn })
1360+
1361+
expect(queryFn).toHaveBeenCalledTimes(1)
1362+
1363+
const observer = new QueryObserver(queryClient, {
1364+
queryKey: key,
1365+
queryFn,
1366+
staleTime: 'static',
1367+
})
1368+
const unsubscribe = observer.subscribe(() => undefined)
1369+
await queryClient.refetchQueries()
1370+
1371+
expect(queryFn).toHaveBeenCalledTimes(1)
1372+
unsubscribe()
1373+
})
13261374
})
13271375

13281376
describe('invalidateQueries', () => {
@@ -1537,6 +1585,25 @@ describe('queryClient', () => {
15371585
expect(abortFn).toHaveBeenCalledTimes(0)
15381586
expect(fetchCount).toBe(1)
15391587
})
1588+
1589+
test('should not refetch static queries after invalidation', async () => {
1590+
const key = queryKey()
1591+
const queryFn = vi.fn(() => 'data1')
1592+
await queryClient.fetchQuery({ queryKey: key, queryFn: queryFn })
1593+
1594+
expect(queryFn).toHaveBeenCalledTimes(1)
1595+
1596+
const observer = new QueryObserver(queryClient, {
1597+
queryKey: key,
1598+
queryFn,
1599+
staleTime: 'static',
1600+
})
1601+
const unsubscribe = observer.subscribe(() => undefined)
1602+
await queryClient.invalidateQueries()
1603+
1604+
expect(queryFn).toHaveBeenCalledTimes(1)
1605+
unsubscribe()
1606+
})
15401607
})
15411608

15421609
describe('resetQueries', () => {

packages/query-core/src/__tests__/queryObserver.test.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,6 +1178,33 @@ describe('queryObserver', () => {
11781178
unsubscribe()
11791179
})
11801180

1181+
test('should not see queries as stale is staleTime is Static', async () => {
1182+
const key = queryKey()
1183+
const observer = new QueryObserver(queryClient, {
1184+
queryKey: key,
1185+
queryFn: async () => {
1186+
await sleep(5)
1187+
return {
1188+
data: 'data',
1189+
}
1190+
},
1191+
staleTime: 'static',
1192+
})
1193+
const result = observer.getCurrentResult()
1194+
expect(result.isStale).toBe(true) // no data = stale
1195+
1196+
const results: Array<QueryObserverResult<unknown>> = []
1197+
const unsubscribe = observer.subscribe((x) => {
1198+
if (x.data) {
1199+
results.push(x)
1200+
}
1201+
})
1202+
1203+
await vi.waitFor(() => expect(results[0]?.isStale).toBe(false))
1204+
1205+
unsubscribe()
1206+
})
1207+
11811208
test('should return a promise that resolves when data is present', async () => {
11821209
const results: Array<QueryObserverResult> = []
11831210
const key = queryKey()
@@ -1346,6 +1373,22 @@ describe('queryObserver', () => {
13461373
unsubscribe()
13471374
})
13481375

1376+
test('should not refetchOnMount when set to "always" when staleTime is Static', async () => {
1377+
const key = queryKey()
1378+
const queryFn = vi.fn(() => 'data')
1379+
queryClient.setQueryData(key, 'initial')
1380+
const observer = new QueryObserver(queryClient, {
1381+
queryKey: key,
1382+
queryFn,
1383+
staleTime: 'static',
1384+
refetchOnMount: 'always',
1385+
})
1386+
const unsubscribe = observer.subscribe(() => undefined)
1387+
await vi.advanceTimersByTimeAsync(1)
1388+
expect(queryFn).toHaveBeenCalledTimes(0)
1389+
unsubscribe()
1390+
})
1391+
13491392
test('should set fetchStatus to idle when _optimisticResults is isRestoring', () => {
13501393
const key = queryKey()
13511394
const observer = new QueryObserver(queryClient, {

packages/query-core/src/query.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
noop,
44
replaceData,
55
resolveEnabled,
6+
resolveStaleTime,
67
skipToken,
78
timeUntilStale,
89
} from './utils'
@@ -24,6 +25,7 @@ import type {
2425
QueryOptions,
2526
QueryStatus,
2627
SetDataOptions,
28+
StaleTime,
2729
} from './types'
2830
import type { QueryObserver } from './queryObserver'
2931
import type { Retryer } from './retryer'
@@ -270,26 +272,44 @@ export class Query<
270272
)
271273
}
272274

273-
isStale(): boolean {
274-
if (this.state.isInvalidated) {
275-
return true
275+
isStatic(): boolean {
276+
if (this.getObserversCount() > 0) {
277+
return this.observers.some(
278+
(observer) =>
279+
resolveStaleTime(observer.options.staleTime, this) === 'static',
280+
)
276281
}
277282

283+
return false
284+
}
285+
286+
isStale(): boolean {
287+
// check observers first, their `isStale` has the source of truth
288+
// calculated with `isStaleByTime` and it takes `enabled` into account
278289
if (this.getObserversCount() > 0) {
279290
return this.observers.some(
280291
(observer) => observer.getCurrentResult().isStale,
281292
)
282293
}
283294

284-
return this.state.data === undefined
295+
return this.state.data === undefined || this.state.isInvalidated
285296
}
286297

287-
isStaleByTime(staleTime = 0): boolean {
288-
return (
289-
this.state.isInvalidated ||
290-
this.state.data === undefined ||
291-
!timeUntilStale(this.state.dataUpdatedAt, staleTime)
292-
)
298+
isStaleByTime(staleTime: StaleTime = 0): boolean {
299+
// no data is always stale
300+
if (this.state.data === undefined) {
301+
return true
302+
}
303+
// static is never stale
304+
if (staleTime === 'static') {
305+
return false
306+
}
307+
// if the query is invalidated, it is stale
308+
if (this.state.isInvalidated) {
309+
return true
310+
}
311+
312+
return !timeUntilStale(this.state.dataUpdatedAt, staleTime)
293313
}
294314

295315
onFocus(): void {

packages/query-core/src/queryClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ export class QueryClient {
324324
const promises = notifyManager.batch(() =>
325325
this.#queryCache
326326
.findAll(filters)
327-
.filter((query) => !query.isDisabled())
327+
.filter((query) => !query.isDisabled() && !query.isStatic())
328328
.map((query) => {
329329
let promise = query.fetch(undefined, fetchOptions)
330330
if (!fetchOptions.throwOnError) {

packages/query-core/src/queryObserver.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -764,7 +764,10 @@ function shouldFetchOn(
764764
(typeof options)['refetchOnWindowFocus'] &
765765
(typeof options)['refetchOnReconnect'],
766766
) {
767-
if (resolveEnabled(options.enabled, query) !== false) {
767+
if (
768+
resolveEnabled(options.enabled, query) !== false &&
769+
resolveStaleTime(options.staleTime, query) !== 'static'
770+
) {
768771
const value = typeof field === 'function' ? field(query) : field
769772

770773
return value === 'always' || (value !== false && isStale(query, options))

packages/query-core/src/types.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,16 @@ export type QueryFunction<
9999
TPageParam = never,
100100
> = (context: QueryFunctionContext<TQueryKey, TPageParam>) => T | Promise<T>
101101

102-
export type StaleTime<
102+
export type StaleTime = number | 'static'
103+
104+
export type StaleTimeFunction<
103105
TQueryFnData = unknown,
104106
TError = DefaultError,
105107
TData = TQueryFnData,
106108
TQueryKey extends QueryKey = QueryKey,
107-
> = number | ((query: Query<TQueryFnData, TError, TData, TQueryKey>) => number)
109+
> =
110+
| StaleTime
111+
| ((query: Query<TQueryFnData, TError, TData, TQueryKey>) => StaleTime)
108112

109113
export type Enabled<
110114
TQueryFnData = unknown,
@@ -329,7 +333,7 @@ export interface QueryObserverOptions<
329333
* If set to a function, the function will be executed with the query to compute a `staleTime`.
330334
* Defaults to `0`.
331335
*/
332-
staleTime?: StaleTime<TQueryFnData, TError, TQueryData, TQueryKey>
336+
staleTime?: StaleTimeFunction<TQueryFnData, TError, TQueryData, TQueryKey>
333337
/**
334338
* If set to a number, the query will continuously refetch at this frequency in milliseconds.
335339
* If set to a function, the function will be executed with the latest data and query to compute a frequency
@@ -502,7 +506,7 @@ export interface FetchQueryOptions<
502506
* The time in milliseconds after data is considered stale.
503507
* If the data is fresh it will be returned from the cache.
504508
*/
505-
staleTime?: StaleTime<TQueryFnData, TError, TData, TQueryKey>
509+
staleTime?: StaleTimeFunction<TQueryFnData, TError, TData, TQueryKey>
506510
}
507511

508512
export interface EnsureQueryDataOptions<

packages/query-core/src/utils.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
QueryKey,
99
QueryOptions,
1010
StaleTime,
11+
StaleTimeFunction,
1112
} from './types'
1213
import type { Mutation } from './mutation'
1314
import type { FetchOptions, Query } from './query'
@@ -102,9 +103,11 @@ export function resolveStaleTime<
102103
TData = TQueryFnData,
103104
TQueryKey extends QueryKey = QueryKey,
104105
>(
105-
staleTime: undefined | StaleTime<TQueryFnData, TError, TData, TQueryKey>,
106+
staleTime:
107+
| undefined
108+
| StaleTimeFunction<TQueryFnData, TError, TData, TQueryKey>,
106109
query: Query<TQueryFnData, TError, TData, TQueryKey>,
107-
): number | undefined {
110+
): StaleTime | undefined {
108111
return typeof staleTime === 'function' ? staleTime(query) : staleTime
109112
}
110113

0 commit comments

Comments
 (0)