Describe the bug
staleTime: 'static' stops being honoured as soon as a query has no observers, so
refetchQueries({ type: 'all' }) and invalidateQueries({ refetchType: 'all' }) re-run the
queryFn for queries the docs promise will never refetch.
Query.isStatic() derives 'static' only from live observers:
isStatic(): boolean {
if (this.getObserversCount() > 0) {
return this.observers.some(
(observer) => resolveStaleTime(observer.options.staleTime, this) === 'static',
)
}
return false // <- an unobserved query is never "static"
}
QueryClient.refetchQueries is its only consumer, using it as the escape hatch
(queryClient.ts:324): .filter((query) => !query.isDisabled() && !query.isStatic()).
This contradicts docs/framework/react/guides/important-defaults.md:15-17:
set staleTime to 'static' to never trigger a refetch, even if the Query is invalidated manually […] queryClient.invalidateQueries() can invalidate a query with staleTime: Infinity, but has no effect on staleTime: 'static'. Use 'static' for data that cannot change while the app is running: feature flags fetched at boot, user permissions loaded at login, static reference tables.
Those named use cases are exactly the ones that break: boot-time data is usually seeded with
prefetchQuery/fetchQuery (which never attach an observer), and permissions loaded at login
lose the guarantee the moment the consuming component unmounts.
Worth noting the neighbouring isDisabled() in the same class does fall back to
this.options when there are no observers, with a comment explaining why:
isDisabled(): boolean {
if (this.getObserversCount() > 0) return !this.isActive()
// if a query has no observers, it should still be considered disabled if it never attempted a fetch
return this.options.queryFn === skipToken || !this.isFetched()
}
So the no-observer fallback is an established pattern here — it is just missing from isStatic.
Your minimal, reproducible example
import { QueryClient } from '@tanstack/query-core'
const qc = new QueryClient()
const fn = vi.fn(async () => 'v')
// boot-time seed — never observed
await qc.fetchQuery({ queryKey: ['flags'], queryFn: fn, staleTime: 'static' })
await qc.refetchQueries({ type: 'all' })
// documented: "never trigger a refetch"
// actual: fn has been called twice
Steps to reproduce
Run against main (c5f2999, query-core 5.101.4) — I used the CONTRIBUTING-sanctioned target
npx vitest run inside packages/query-core. Three separate paths, all reproducing:
A prefetchQuery, never observed observers=0 isStatic=false isDisabled=false calls=2
C fetchQuery, never observed observers=0 isStatic=false isDisabled=false calls=2
D observer subscribed then unmounted observers=0 isStatic=false isDisabled=false calls=2
(after waiting for isFetched()===true, so isDisabled does not mask it)
A caveat that cost me a false negative and is worth stating: if you unsubscribe before the
fetch lands in query state, isFetched() is still false, so isDisabled() returns true and
filters the query out anyway — the bug looks absent. Waiting for isFetched() first is what
exposes it.
Expected behavior
refetchQueries / invalidateQueries({ refetchType: 'all' }) should skip a query whose own
staleTime resolves to 'static', regardless of whether it currently has observers.
Possible fix, and the API question behind it
Mirroring isDisabled() works and I have it passing locally:
// no observers: fall back to the query's own options
return resolveStaleTime(this.options.staleTime, this) === 'static'
Verified with that change (same three paths, plus two guards against over-blocking):
A prefetchQuery calls=1
C fetchQuery calls=1
D unmounted observer calls=1
E non-static query calls=2 <- still refetches, no over-blocking
F staleTime: Infinity calls=2 <- still invalidatable, per the documented distinction
Full packages/query-core suite: 554 passed.
But it does not typecheck as-is, and that is the real decision:
TypeCheckError: Property 'staleTime' does not exist on type 'QueryOptions<...>'.
❯ src/query.ts:304
Query.options is typed QueryOptions, and staleTime is declared on
QueryObserverOptions (types.ts:331) and separately re-added by FetchQueryOptions
(types.ts:504). Query stores whatever it is constructed with
(this.options = { ...defaultOptions, ...options }), so the value is present at runtime while
the type says it cannot be — which is why isStatic could not read it in the first place.
Two ways out, and the choice is yours rather than mine:
- Declare
staleTime on QueryOptions and drop the duplicate from FetchQueryOptions.
Honest about what Query already stores, but it widens a public type.
- Keep the types as they are and have
isStatic read the value through a narrower
internal accessor.
I did not want to pick a public-API direction unilaterally in a library this widely used, so I
have the fix and tests ready rather than opening a PR with that decision baked in. Happy to send
whichever you prefer — or neither, if you would rather solve it another way.
Describe the bug
staleTime: 'static'stops being honoured as soon as a query has no observers, sorefetchQueries({ type: 'all' })andinvalidateQueries({ refetchType: 'all' })re-run thequeryFnfor queries the docs promise will never refetch.Query.isStatic()derives 'static' only from live observers:QueryClient.refetchQueriesis its only consumer, using it as the escape hatch(
queryClient.ts:324):.filter((query) => !query.isDisabled() && !query.isStatic()).This contradicts
docs/framework/react/guides/important-defaults.md:15-17:Those named use cases are exactly the ones that break: boot-time data is usually seeded with
prefetchQuery/fetchQuery(which never attach an observer), and permissions loaded at loginlose the guarantee the moment the consuming component unmounts.
Worth noting the neighbouring
isDisabled()in the same class does fall back tothis.optionswhen there are no observers, with a comment explaining why:So the no-observer fallback is an established pattern here — it is just missing from
isStatic.Your minimal, reproducible example
Steps to reproduce
Run against
main(c5f2999, query-core5.101.4) — I used the CONTRIBUTING-sanctioned targetnpx vitest runinsidepackages/query-core. Three separate paths, all reproducing:A caveat that cost me a false negative and is worth stating: if you unsubscribe before the
fetch lands in query state,
isFetched()is stillfalse, soisDisabled()returnstrueandfilters the query out anyway — the bug looks absent. Waiting for
isFetched()first is whatexposes it.
Expected behavior
refetchQueries/invalidateQueries({ refetchType: 'all' })should skip a query whose ownstaleTimeresolves to'static', regardless of whether it currently has observers.Possible fix, and the API question behind it
Mirroring
isDisabled()works and I have it passing locally:Verified with that change (same three paths, plus two guards against over-blocking):
Full
packages/query-coresuite: 554 passed.But it does not typecheck as-is, and that is the real decision:
Query.optionsis typedQueryOptions, andstaleTimeis declared onQueryObserverOptions(types.ts:331) and separately re-added byFetchQueryOptions(types.ts:504).
Querystores whatever it is constructed with(
this.options = { ...defaultOptions, ...options }), so the value is present at runtime whilethe type says it cannot be — which is why
isStaticcould not read it in the first place.Two ways out, and the choice is yours rather than mine:
staleTimeonQueryOptionsand drop the duplicate fromFetchQueryOptions.Honest about what
Queryalready stores, but it widens a public type.isStaticread the value through a narrowerinternal accessor.
I did not want to pick a public-API direction unilaterally in a library this widely used, so I
have the fix and tests ready rather than opening a PR with that decision baked in. Happy to send
whichever you prefer — or neither, if you would rather solve it another way.