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..1e37915d2c8 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -165,6 +165,48 @@ 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/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' diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index b97b2cc5a33..8df3241d199 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,19 @@ 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