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..1a6e3a2757d 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -296,18 +296,19 @@ export class QueryClient { ): Promise { return notifyManager.batch(() => { this.#queryCache.findAll(filters).forEach((query) => { - query.invalidate() + query.invalidate(options.meta) }) if (filters?.refetchType === 'none') { return Promise.resolve() } + const { meta: _meta, ...refetchOptions } = options return this.refetchQueries( { ...filters, type: filters?.refetchType ?? filters?.type ?? 'active', }, - options, + refetchOptions, ) }) } 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 {