[query-core] [performances] `setQueryData` dispatches a `success` event (and triggers broadcast/persist) even when the new data is structurally identical to the cached data
Les mainteneurs répondent en général sous 1 jour
Évaluation
- Difficulté
- 4/5
- Temps estimé
- 3-5 jours
- Accessibilité débutants
- 52/100
- Type d'issue
- Fonctionnalité
- Clarté
- Clairement spécifiée
- Activité
- Active
- Stack technique
- typescript
- Domaine
- backend-api-design, performance
Piste de recherche
Start by reading setData and replaceData in the query core, then trace how setQueryData and setQueriesData call them. Check the tests covering manual cache writes, cache events, and explicit updatedAt values. Done means structurally identical manual writes can skip success events while explicit timestamp updates and fetch writes retain their intended behavior.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Description
Describe the bug
queryClient.setQueryData always writes the new data through Query#setData, which dispatches a success cache event, increments state.dataUpdateCount and refreshes timestamps — even when the new data is structurally identical to what is already cached.
Since v5, setData already runs a deep comparison on every write for structural sharing (replaceData), so the equality is already computed — but it is only used to preserve references, not to skip the write.
Minimal repro (no framework needed, @tanstack/query-core 5.105.0):
import { QueryClient } from '@tanstack/query-core';
const client = new QueryClient();
let events = 0;
client.getQueryCache().subscribe((cacheEvent) => {
if (cacheEvent.type === 'event' && cacheEvent.event.type === 'success') events++;
});
const base = { id: '1', name: 'item', meta: { tag: 'a', n: 1 } };
client.setQueryData(['item', '1'], base);
// Same content, new reference — the typical upsert pattern used by cache-sync helpers:
for (let i = 0; i < 10; i++) {
client.setQueryData(['item', '1'], (old) => ({ ...old, ...JSON.parse(JSON.stringify(base)) }));
}
console.log(events); // 11 — ten extra 'success' events for data that never changed
console.log(client.getQueryCache().find({ queryKey: ['item', '1'] })!.state.dataUpdateCount); // 11
Expected: a manual write whose result is structurally identical to the current data should not dispatch a cache event (or at least should be able to).
Why it matters
The no-op write is invisible to components — because the structural-sharing result is referentially identical to the old data, observers do not re-render — so the cost never shows up as a visible bug. But every dispatched success event is still fully processed by:
@tanstack/query-broadcast-client-experimental— on everysuccessevent it structured-clones the query's full state and posts it on the channel.@tanstack/query-persist-client-corewith manualpersistQueryByKey(the pattern recommended for manual cache updates, see #6310) — every call writes the full state to storage, and an IndexedDBputstructured-clones its value synchronously on the main thread.
So an application that upserts a parent entity plus its N children into the cache per page transition (a common normalized-cache pattern) pays N state clones + N cross-tab posts per transition even when nothing changed.
Measured in an application with that pattern (one page transition entering an edit form that upserts ~10 cached entities, warm cache):
| no guard | no-op skip | |
|---|---|---|
success events dispatched |
609 | 193 |
| full-state writes to IndexedDB | 218 | 16 |
The 416 / 202 eliminated operations were all structurally no-op writes. (We currently work around this at the application level with a deep-equality pre-check in a setQueryData wrapper plus a persistQueryByKey dedup keyed on dataUpdateCount; an in-library fix would cover all call sites at once.)
Suggested fix
setData already computes the shared result, and when the whole tree is structurally identical, replaceData returns the previous reference itself — so the short-circuit is a zero-cost reference comparison:
setData(newData, options) {
const data = replaceData(this.state.data, newData, this.options);
if (options?.manual && data === this.state.data) return data; // no-op manual write: skip the dispatch
this.#dispatch({ data, type: 'success', dataUpdatedAt: options?.updatedAt, manual: options?.manual });
return data;
}
Scoping to manual: true (writes from setQueryData / setQueriesData) leaves the fetch path untouched.
Behavior change to flag
dataUpdatedAt/dataUpdateCountwould not bump on a skipped no-op write. For manual writes this seems correct (nothing was updated), but it is an observable change.- A call that passes an explicit
updatedAtoption should probably still dispatch, since the caller explicitly asked for a timestamp change.
Versions
"@tanstack/eslint-plugin-query": "5.105.0",
"@tanstack/query-broadcast-client-experimental": "5.105.0",
"@tanstack/query-persist-client-core": "5.105.0",
"@tanstack/svelte-query": "6.3.6",
"@tanstack/svelte-query-devtools": "6.3.6",
- Langage dominant
- TypeScript
- Étoiles
- 50.5k
- Forks
- 4.2k
- Merge moyen
- 5 h 40 min
- PR mergées (30 j)
- 574
Préparer son environnement
- Aucun Dockerfile ni fichier Docker Compose
- Propose un modèle de pull request
- Lire le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Autres issues de TanStack/query
-
solid-query: STRICT_READ_UNTRACKED on Solid 2 — client()/options() read in component body (useMutation, useBaseQuery)Peut-être à nouveau libre @LeonxLJX l’a pris il y a 57 jours, et aucune pull request n’est ouverte. Ouverte
Difficulté 2/5 1-3 heures Accessibilité débutants 84/100
TanStack/query#11358 · 2 commentaires · 1 réaction ·
Les mainteneurs répondent en général sous 1 jour
-
refetchQueries with throwOnError leaves paused fetch rejection unhandled on client.clear()Peut-être pris @lazerg l’a pris il y a 2 jours. Ouverte
Difficulté 3/5 1-2 jours Accessibilité débutants 67/100
Les mainteneurs répondent en général sous 1 jour
-
useBaseQuery fetches the old query key when an <Activity> tree is revealedPeut-être pris @lazerg l’a pris il y a 10 jours. Ouverte
Difficulté 3/5 1-2 jours Accessibilité débutants 35/100
Les mainteneurs répondent en général sous 1 jour
-
[query-broadcast-client-experimental] broadcastQueryClient: an `added` event overwrites a receiving query that has no data (e.g. in error) with the sender's empty statePeut-être pris @lazerg l’a pris il y a 11 jours. Ouverte
Difficulté 2/5 1-3 heures Accessibilité débutants 25/100
Les mainteneurs répondent en général sous 1 jour
-
solid-query 6.0.0-rc.5: onCacheEvent throws NotReadyError during a Solid 2 (rc.14+) transition landingPeut-être pris @UzumakiODZ l’a pris il y a 16 jours. Ouverte
Difficulté 4/5 3-5 jours Accessibilité débutants 28/100
TanStack/query#12031 · 2 commentaires ·
Les mainteneurs répondent en général sous 1 jour
Toutes les issues de TanStack/query
Issues similaires
-
Difficulté 2/5 1-3 heures Accessibilité débutants 76/100
Northeastern-Electric-Racing/FinishLine#4660 ·
Les mainteneurs répondent en général sous 2 jours
-
BeOS .json missingOuverte
Difficulté 2/5 1-3 heures Accessibilité débutants 72/100
mihaip/infinite-mac#475 ·
-
Let TypeScript accept an initial object in ReactiveDictPeut-être pris @yograj-vijay-yadav l’a pris aujourd’hui. Ouvertebug good first issue help wanted workshop
Difficulté 2/5 1-3 heures Accessibilité débutants 92/100
meteor/meteor#14919 · 1 commentaire ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 2/5 1-3 heures Accessibilité débutants 83/100
marmelab/atomic-crm#401 ·
Les mainteneurs répondent en général sous 1 jour
-
account: profile
Difficulté 2/5 1-3 heures Accessibilité débutants 75/100
Les mainteneurs répondent en général sous 5 jours