Skip to content

staleTime: 'static' is ignored once a query has no observers, so refetchQueries/invalidateQueries refetch it #11190

Description

@lets-order-some-fries

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:

  1. Declare staleTime on QueryOptions and drop the duplicate from FetchQueryOptions.
    Honest about what Query already stores, but it widens a public type.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions