Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions packages/query-core/src/__tests__/queryClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
42 changes: 42 additions & 0 deletions packages/query-core/src/__tests__/utils.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/query-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
71 changes: 65 additions & 6 deletions packages/query-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ export interface QueryFilters<TQueryKey extends QueryKey = QueryKey> {
* 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<
Expand All @@ -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<TInput, TOutput> = TOutput | ((input: TInput) => TOutput)
Expand Down Expand Up @@ -148,6 +165,7 @@ export function matchQuery(
type = 'all',
exact,
fetchStatus,
ignoreUndefinedInKeys,
predicate,
queryKey,
stale,
Expand All @@ -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
}
}
Expand Down Expand Up @@ -192,7 +212,8 @@ export function matchMutation(
filters: MutationFilters,
mutation: Mutation<any, any>,
): boolean {
const { exact, status, predicate, mutationKey } = filters
const { exact, ignoreUndefinedInKeys, status, predicate, mutationKey } =
filters
if (mutationKey) {
if (!mutation.options.mutationKey) {
return false
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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
}
Expand All @@ -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
Expand Down