Query Invalidation
Frontend data fetching in Checkstack runs on oRPC procedure proxies (per
plugin client, e.g. healthCheckClient, catalogClient) that wrap
TanStack Query. The QueryClient is configured globally in
core/frontend/src/App.tsx
with staleTime: 30s and gcTime: 5min — i.e. results are served
stale-while-revalidate and cached past unmount.
To keep views fresh without each call site reinventing the wheel, follow the three pillars below.
Pillar 1 — Within-plugin mutations: do nothing
Section titled “Pillar 1 — Within-plugin mutations: do nothing”Every useMutation() produced by an oRPC plugin client already runs
queryClient.invalidateQueries({ queryKey: [[pluginId]] });on success (see
core/frontend-api/src/orpc-query.tsx).
That marks every query for that plugin stale and triggers a refetch
for any active observer.
If you spot legacy refetch() calls inside same-plugin mutation
onSuccess handlers, treat them as cleanup candidates — they were
written before automatic invalidation existed.
Pillar 2 — Cross-plugin mutations: invalidate explicitly
Section titled “Pillar 2 — Cross-plugin mutations: invalidate explicitly”The auto-invalidator only knows about the owning plugin. If a mutation in plugin A changes data that plugin B displays, you must invalidate B yourself:
const queryClient = useQueryClient();
const subscribe = notificationClient.subscribe.useMutation({ onSuccess: () => { void queryClient.invalidateQueries({ queryKey: [["healthcheck"]] }); },});This mirrors the realtime foreignSignals mechanism used by
SignalAutoInvalidator — declare the dependency at the call site.
Pillar 3 — One-shot editors: opt out of stale-while-revalidate
Section titled “Pillar 3 — One-shot editors: opt out of stale-while-revalidate”Editors that seed local form state from a query exactly once (e.g. via
useInitOnceForKey) are vulnerable to a subtle race:
- Mutation succeeds → cache is marked stale (Pillar 1).
- User navigates away. Editor unmounts; cache survives
gcTime. - User reopens the same entity.
- Query mounts → serves cached stale value synchronously while refetching in the background.
useInitOnceForKeyfires on render 1 with the stale value, then refuses to re-init when the fresh value arrives — keys haven’t changed.
Result: the form is seeded from pre-mutation data. Symptom: deleted items reappear, edits look reverted, etc. A hard refresh “fixes” it by dropping the cache entirely.
Rule: for any query whose result drives a one-shot local-state init, disable cross-mount caching:
const { data: existingConfig } = healthCheckClient.getConfiguration.useQuery( { id: configId ?? "" }, { enabled: isEditMode, // Editor seeds form state once via useInitOnceForKey; serving a // stale cached value on remount would race the one-shot init. gcTime: 0, }, );gcTime: 0 drops the cached entry as soon as the query has no
observers (i.e. on unmount), so the next mount has no stale value to
serve — the loader shows its loading state and useInitOnceForKey
fires once with fresh data.
The alternative — calling
queryClient.removeQueries({ queryKey: ... }) inside the mutation’s
onSuccess — works but couples the mutator to the loader’s query key
and has to be repeated for every editor that reads the same data. The
gcTime: 0 approach localises the contract to the editor itself.