From 1418798bce9f5dc63d981c0d07aea6df82c38cea Mon Sep 17 00:00:00 2001 From: Mathis Pinsault Date: Mon, 20 Apr 2026 22:15:00 +0200 Subject: [PATCH 1/2] feat(query-core): forward caller meta through invalidateQueries into cache action Adds an optional `meta` field to `InvalidateOptions` that flows through to the `invalidate` action payload visible in `queryCache.subscribe`, enabling structured observability and echo-suppression without out-of-band bookkeeping. Closes #10539 (discussion) Co-Authored-By: Claude Sonnet 4.6 --- .changeset/invalidate-queries-meta.md | 18 ++++++++++++ .../react/guides/query-invalidation.md | 28 +++++++++++++++++++ docs/reference/QueryClient.md | 3 ++ .../src/__tests__/queryClient.test.tsx | 21 ++++++++++++++ packages/query-core/src/query.ts | 5 ++-- packages/query-core/src/queryClient.ts | 2 +- packages/query-core/src/types.ts | 4 ++- 7 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 .changeset/invalidate-queries-meta.md diff --git a/.changeset/invalidate-queries-meta.md b/.changeset/invalidate-queries-meta.md new file mode 100644 index 00000000000..c11e0ce913a --- /dev/null +++ b/.changeset/invalidate-queries-meta.md @@ -0,0 +1,18 @@ +--- +"@tanstack/query-core": minor +--- + +Forward caller-provided `meta` from `invalidateQueries` options into the `invalidate` action payload visible in `queryCache.subscribe`. + +```ts +queryClient.invalidateQueries( + { queryKey: ['orders'] }, + { meta: { source: 'websocket', traceId: 'abc123' } }, +) + +queryCache.subscribe((event) => { + if (event.type === 'updated' && event.action.type === 'invalidate') { + console.log(event.action.meta) // { source: 'websocket', traceId: 'abc123' } + } +}) +``` diff --git a/docs/framework/react/guides/query-invalidation.md b/docs/framework/react/guides/query-invalidation.md index 3981b4d221a..7ab314d6b7a 100644 --- a/docs/framework/react/guides/query-invalidation.md +++ b/docs/framework/react/guides/query-invalidation.md @@ -131,3 +131,31 @@ const todoListQuery = useQuery({ ``` [//]: # 'Example5' + +## Tracing invalidation sources with `meta` + +Pass a `meta` object as the second argument to `invalidateQueries` to attach caller context to the invalidation. The value flows through to the `invalidate` action seen by `queryCache.subscribe` subscribers, making it possible to build structured logs or suppress echo-invalidations without any out-of-band bookkeeping. + +[//]: # 'ExampleMeta' + +```tsx +// Attach a source tag when invalidating from a WebSocket message +queryClient.invalidateQueries( + { queryKey: ['orders'] }, + { meta: { source: 'websocket', traceId: 'abc123' } }, +) + +// Read it back in a cache subscriber +queryCache.subscribe((event) => { + if (event.type === 'updated' && event.action.type === 'invalidate') { + analytics.track('cache.invalidated', { + queryKey: event.query.queryKey, + ...event.action.meta, + }) + } +}) +``` + +[//]: # 'ExampleMeta' + +> **Note:** `meta` in the second argument (`options.meta`) is unrelated to `filters.meta` in the first argument. `filters.meta` selects _which_ queries to invalidate; `options.meta` is arbitrary context that rides along with the invalidation action. diff --git a/docs/reference/QueryClient.md b/docs/reference/QueryClient.md index 13bde1ffad0..144f6b3c461 100644 --- a/docs/reference/QueryClient.md +++ b/docs/reference/QueryClient.md @@ -351,6 +351,9 @@ await queryClient.invalidateQueries( - Defaults to `true` - Per default, a currently running request will be cancelled before a new request is made - When set to `false`, no refetch will be made if there is already a request running. + - `meta?: QueryMeta` + - Optional metadata to attach to the invalidation. The value is forwarded to the `invalidate` action payload visible in `queryCache.subscribe`, allowing cache subscribers to identify the source of the invalidation. + - Note: this is distinct from `filters.meta`, which filters queries _by_ their meta. ## `queryClient.refetchQueries` diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index c09db304467..7f19b8ea642 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -1600,6 +1600,27 @@ describe('queryClient', () => { expect(queryFn).toHaveBeenCalledTimes(1) unsubscribe() }) + + it('should forward meta to the invalidate action in queryCache.subscribe', async () => { + const key = queryKey() + await queryClient.prefetchQuery({ queryKey: key, queryFn: () => 'data' }) + + const events: Array = [] + const unsubscribe = queryCache.subscribe((event) => { + if (event.type === 'updated' && event.action.type === 'invalidate') { + events.push(event.action.meta) + } + }) + + await queryClient.invalidateQueries( + { queryKey: key }, + { meta: { source: 'websocket', traceId: 'abc123' } }, + ) + + unsubscribe() + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ source: 'websocket', traceId: 'abc123' }) + }) }) describe('resetQueries', () => { diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 7dfaa587721..e164cc0a235 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -124,6 +124,7 @@ interface ErrorAction { interface InvalidateAction { type: 'invalidate' + meta?: QueryMeta } interface PauseAction { @@ -388,9 +389,9 @@ export class Query< ) } - invalidate(): void { + invalidate(meta?: QueryMeta): void { if (!this.state.isInvalidated) { - this.#dispatch({ type: 'invalidate' }) + this.#dispatch({ type: 'invalidate', meta }) } } diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 80cc36668aa..45da7e16e97 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -296,7 +296,7 @@ export class QueryClient { ): Promise { return notifyManager.batch(() => { this.#queryCache.findAll(filters).forEach((query) => { - query.invalidate() + query.invalidate(options.meta) }) if (filters?.refetchType === 'none') { diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 4f3f4caed20..48bc015aa5b 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -591,7 +591,9 @@ export interface RefetchQueryFilters< TQueryKey extends QueryKey = QueryKey, > extends QueryFilters {} -export interface InvalidateOptions extends RefetchOptions {} +export interface InvalidateOptions extends RefetchOptions { + meta?: QueryMeta +} export interface ResetOptions extends RefetchOptions {} export interface FetchNextPageOptions extends ResultOptions { From 3cfeb2e93a3d91256ec77d8fb0e3d12da9639df2 Mon Sep 17 00:00:00 2001 From: Mathis Pinsault Date: Tue, 21 Apr 2026 08:51:52 +0200 Subject: [PATCH 2/2] fix(query-core): strip meta from options before forwarding to refetchQueries meta is specific to the invalidate action and should not leak into the refetch call that follows. Co-Authored-By: Claude Sonnet 4.6 --- packages/query-core/src/queryClient.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 45da7e16e97..1a6e3a2757d 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -302,12 +302,13 @@ export class QueryClient { if (filters?.refetchType === 'none') { return Promise.resolve() } + const { meta: _meta, ...refetchOptions } = options return this.refetchQueries( { ...filters, type: filters?.refetchType ?? filters?.type ?? 'active', }, - options, + refetchOptions, ) }) }