From 3a255292204835725eb9cc77ee414caaf7e8c60e Mon Sep 17 00:00:00 2001 From: webdevelopersrinu Date: Thu, 21 May 2026 21:38:32 +0530 Subject: [PATCH 1/3] feat(query-core): add opt-in `ignoreUndefinedInKeys` filter option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes inconsistency where `hashKey` strips `undefined` object properties (via `JSON.stringify`) but `partialMatchKey` keeps them, causing `invalidateQueries({ queryKey: [{ ...filter: undefined }] })` to miss cached entries that have a concrete value for the same property. The new option is opt-in (default `false`) — no change to existing behavior — so it is fully backward-compatible. Threaded through both `QueryFilters` and `MutationFilters`. Adds unit tests for `partialMatchKey` covering the broken case, the nested-object case, and array preservation. Adds an end-to-end `invalidateQueries` integration test demonstrating the fix at the public API surface. Refs #3741 --- .../src/__tests__/queryClient.test.tsx | 47 ++++++++++++ .../query-core/src/__tests__/utils.test.tsx | 40 ++++++++++ packages/query-core/src/utils.ts | 73 +++++++++++++++++-- 3 files changed, 154 insertions(+), 6 deletions(-) diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index c09db304467..b34fca2311c 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -1600,6 +1600,53 @@ describe('queryClient', () => { expect(queryFn).toHaveBeenCalledTimes(1) unsubscribe() }) + + // Regression test for https://github.com/TanStack/query/issues/3741 + it('should match keys with `undefined` properties when ignoreUndefinedInKeys is set', async () => { + const queryFnAll = vi.fn().mockReturnValue('all') + const queryFnFiltered = vi.fn().mockReturnValue('filtered') + + await queryClient.fetchQuery({ + queryKey: [{ entity: 'todos', scope: 'list' }], + queryFn: queryFnAll, + }) + await queryClient.fetchQuery({ + queryKey: [{ entity: 'todos', scope: 'list', filter: { done: true } }], + queryFn: queryFnFiltered, + }) + + const observerAll = new QueryObserver(queryClient, { + queryKey: [{ entity: 'todos', scope: 'list' }], + queryFn: queryFnAll, + staleTime: Infinity, + }) + const observerFiltered = new QueryObserver(queryClient, { + queryKey: [{ entity: 'todos', scope: 'list', filter: { done: true } }], + queryFn: queryFnFiltered, + staleTime: Infinity, + }) + const u1 = observerAll.subscribe(() => undefined) + const u2 = observerFiltered.subscribe(() => undefined) + + // Without the option: undefined-keyed invalidation only hits the + // unfiltered cache entry — the filtered one is NOT refetched. + await queryClient.invalidateQueries({ + queryKey: [{ entity: 'todos', scope: 'list', filter: undefined }], + }) + expect(queryFnAll).toHaveBeenCalledTimes(2) + expect(queryFnFiltered).toHaveBeenCalledTimes(1) + + // With the option: undefined property is ignored → both entries match. + await queryClient.invalidateQueries({ + queryKey: [{ entity: 'todos', scope: 'list', filter: undefined }], + ignoreUndefinedInKeys: true, + }) + expect(queryFnAll).toHaveBeenCalledTimes(3) + expect(queryFnFiltered).toHaveBeenCalledTimes(2) + + u1() + u2() + }) }) describe('resetQueries', () => { diff --git a/packages/query-core/src/__tests__/utils.test.tsx b/packages/query-core/src/__tests__/utils.test.tsx index 69c72a50aa8..ce668f1c624 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -165,6 +165,46 @@ describe('core/utils', () => { const b = [{ a: null, c: 'c', d: [{ d: 'd ' }] }] expect(partialMatchKey(a, b)).toEqual(false) }) + + // Regression tests for https://github.com/TanStack/query/issues/3741 + describe('with ignoreUndefinedInKeys option', () => { + it('should match when b has `undefined` for a key that holds a concrete value in a — only when option is enabled', () => { + const a = [{ entity: 'todos', filter: { done: true } }] + const b = [{ entity: 'todos', filter: undefined }] + // Default behavior: typeof mismatch between {done:true} and undefined → no match + expect(partialMatchKey(a, b)).toEqual(false) + // Opt-in: undefined in b is ignored → falls back to entity-only match + expect(partialMatchKey(a, b, { ignoreUndefinedInKeys: true })).toEqual( + true, + ) + }) + + it('should still not match when both sides have concrete but different values', () => { + const a = [{ entity: 'todos', filter: { done: true } }] + const b = [{ entity: 'todos', filter: { done: false } }] + expect( + partialMatchKey(a, b, { ignoreUndefinedInKeys: true }), + ).toEqual(false) + }) + + it('should not strip undefined from arrays inside the key', () => { + const a = [{ entity: 'todos', tags: ['urgent'] }] + const b = [{ entity: 'todos', tags: [undefined] }] + expect( + partialMatchKey(a, b, { ignoreUndefinedInKeys: true }), + ).toEqual(false) + }) + + it('should match recursively in nested objects', () => { + const a = [{ entity: 'todos', filter: { done: true, owner: 'me' } }] + const b = [{ entity: 'todos', filter: { done: true, owner: undefined } }] + // Default: owner undefined vs 'me' → typeof mismatch → false + expect(partialMatchKey(a, b)).toEqual(false) + expect( + partialMatchKey(a, b, { ignoreUndefinedInKeys: true }), + ).toEqual(true) + }) + }) }) describe('replaceEqualDeep', () => { diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index b97b2cc5a33..adaf041b233 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -52,6 +52,15 @@ export interface QueryFilters { * Include queries matching their fetchStatus */ fetchStatus?: FetchStatus + /** + * When `true`, object properties whose value is `undefined` are ignored + * during partial query key matching. Mirrors the default `hashKey` + * behavior which already strips `undefined` via `JSON.stringify`. + * + * @default false + * @see https://github.com/TanStack/query/issues/3741 + */ + ignoreUndefinedInKeys?: boolean } export interface MutationFilters< @@ -78,6 +87,14 @@ export interface MutationFilters< * Filter by mutation status */ status?: MutationStatus + /** + * When `true`, object properties whose value is `undefined` are ignored + * during partial mutation key matching. + * + * @default false + * @see https://github.com/TanStack/query/issues/3741 + */ + ignoreUndefinedInKeys?: boolean } export type Updater = TOutput | ((input: TInput) => TOutput) @@ -148,6 +165,7 @@ export function matchQuery( type = 'all', exact, fetchStatus, + ignoreUndefinedInKeys, predicate, queryKey, stale, @@ -158,7 +176,9 @@ export function matchQuery( if (query.queryHash !== hashQueryKeyByOptions(queryKey, query.options)) { return false } - } else if (!partialMatchKey(query.queryKey, queryKey)) { + } else if ( + !partialMatchKey(query.queryKey, queryKey, { ignoreUndefinedInKeys }) + ) { return false } } @@ -192,7 +212,8 @@ export function matchMutation( filters: MutationFilters, mutation: Mutation, ): boolean { - const { exact, status, predicate, mutationKey } = filters + const { exact, ignoreUndefinedInKeys, status, predicate, mutationKey } = + filters if (mutationKey) { if (!mutation.options.mutationKey) { return false @@ -201,7 +222,11 @@ export function matchMutation( if (hashKey(mutation.options.mutationKey) !== hashKey(mutationKey)) { return false } - } else if (!partialMatchKey(mutation.options.mutationKey, mutationKey)) { + } else if ( + !partialMatchKey(mutation.options.mutationKey, mutationKey, { + ignoreUndefinedInKeys, + }) + ) { return false } } @@ -242,11 +267,33 @@ export function hashKey(queryKey: QueryKey | MutationKey): string { ) } +export type PartialMatchKeyOptions = { + /** + * When `true`, object properties whose value is `undefined` are treated as + * missing in both keys before comparison. This matches the behavior of the + * default `hashKey` (which strips `undefined` via `JSON.stringify`). + * + * Does NOT apply to `undefined` values inside arrays. + * + * @default false + * @see https://github.com/TanStack/query/issues/3741 + */ + ignoreUndefinedInKeys?: boolean +} + /** * Checks if key `b` partially matches with key `a`. */ -export function partialMatchKey(a: QueryKey, b: QueryKey): boolean -export function partialMatchKey(a: any, b: any): boolean { +export function partialMatchKey( + a: QueryKey, + b: QueryKey, + options?: PartialMatchKeyOptions, +): boolean +export function partialMatchKey( + a: any, + b: any, + options?: PartialMatchKeyOptions, +): boolean { if (a === b) { return true } @@ -256,7 +303,21 @@ export function partialMatchKey(a: any, b: any): boolean { } if (a && b && typeof a === 'object' && typeof b === 'object') { - return Object.keys(b).every((key) => partialMatchKey(a[key], b[key])) + if ( + options?.ignoreUndefinedInKeys && + isPlainObject(a) && + isPlainObject(b) + ) { + return Object.keys(b) + .filter((key) => b[key] !== undefined) + .every((key) => + partialMatchKey(a[key] as any, b[key] as any, options), + ) + } + + return Object.keys(b).every((key) => + partialMatchKey(a[key], b[key], options), + ) } return false From 5d0ee2883278b96b7f221a41b5159a755391a039 Mon Sep 17 00:00:00 2001 From: webdevelopersrinu Date: Tue, 26 May 2026 14:57:06 +0530 Subject: [PATCH 2/3] style(query-core): apply prettier formatting --- .../query-core/src/__tests__/utils.test.tsx | 22 ++++++++++--------- packages/query-core/src/utils.ts | 4 +--- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/query-core/src/__tests__/utils.test.tsx b/packages/query-core/src/__tests__/utils.test.tsx index ce668f1c624..1e37915d2c8 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -182,27 +182,29 @@ describe('core/utils', () => { it('should still not match when both sides have concrete but different values', () => { const a = [{ entity: 'todos', filter: { done: true } }] const b = [{ entity: 'todos', filter: { done: false } }] - expect( - partialMatchKey(a, b, { ignoreUndefinedInKeys: true }), - ).toEqual(false) + expect(partialMatchKey(a, b, { ignoreUndefinedInKeys: true })).toEqual( + false, + ) }) it('should not strip undefined from arrays inside the key', () => { const a = [{ entity: 'todos', tags: ['urgent'] }] const b = [{ entity: 'todos', tags: [undefined] }] - expect( - partialMatchKey(a, b, { ignoreUndefinedInKeys: true }), - ).toEqual(false) + expect(partialMatchKey(a, b, { ignoreUndefinedInKeys: true })).toEqual( + false, + ) }) it('should match recursively in nested objects', () => { const a = [{ entity: 'todos', filter: { done: true, owner: 'me' } }] - const b = [{ entity: 'todos', filter: { done: true, owner: undefined } }] + const b = [ + { entity: 'todos', filter: { done: true, owner: undefined } }, + ] // Default: owner undefined vs 'me' → typeof mismatch → false expect(partialMatchKey(a, b)).toEqual(false) - expect( - partialMatchKey(a, b, { ignoreUndefinedInKeys: true }), - ).toEqual(true) + expect(partialMatchKey(a, b, { ignoreUndefinedInKeys: true })).toEqual( + true, + ) }) }) }) diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index adaf041b233..8df3241d199 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -310,9 +310,7 @@ export function partialMatchKey( ) { return Object.keys(b) .filter((key) => b[key] !== undefined) - .every((key) => - partialMatchKey(a[key] as any, b[key] as any, options), - ) + .every((key) => partialMatchKey(a[key] as any, b[key] as any, options)) } return Object.keys(b).every((key) => From 96d3a9e3b5dc69113c28260a7ae8c0020a2fcd38 Mon Sep 17 00:00:00 2001 From: webdevelopersrinu Date: Tue, 26 May 2026 15:02:03 +0530 Subject: [PATCH 3/3] feat(query-core): export PartialMatchKeyOptions from package root --- packages/query-core/src/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index a4267aabc97..4163a766633 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -38,7 +38,13 @@ export { shouldThrowError, skipToken, } from './utils' -export type { MutationFilters, QueryFilters, SkipToken, Updater } from './utils' +export type { + MutationFilters, + PartialMatchKeyOptions, + QueryFilters, + SkipToken, + Updater, +} from './utils' export { streamedQuery as experimental_streamedQuery } from './streamedQuery'