Skip to content

perf: setQueryData O(n²) performance bottleneck and optimize replaceEqualDeep for large datasets #9392

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
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
2 changes: 1 addition & 1 deletion packages/query-core/src/queryCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export interface QueryStore {
// CLASS

export class QueryCache extends Subscribable<QueryCacheListener> {
#queries: QueryStore
#queries: Map<string, Query>

constructor(public config: QueryCacheConfig = {}) {
super()
Expand Down
2 changes: 2 additions & 0 deletions packages/query-core/src/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export class QueryClient {
>,
options?: SetDataOptions,
): NoInfer<TInferredQueryFnData> | undefined {
// Improve performance by directly using the queryHash instead of using find()
const defaultedOptions = this.defaultQueryOptions<
any,
any,
Expand All @@ -193,6 +194,7 @@ export class QueryClient {
QueryKey
>({ queryKey })

// Direct O(1) hash map lookup instead of O(n) find()
const query = this.#queryCache.get<TInferredQueryFnData>(
defaultedOptions.queryHash,
)
Expand Down
122 changes: 105 additions & 17 deletions packages/query-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,11 +250,28 @@ export function partialMatchKey(a: any, b: any): boolean {
* If not, it will replace any deeply equal children of `b` with those of `a`.
* This can be used for structural sharing between JSON values for example.
*/
// WeakMap for memoization to avoid processing the same objects multiple times
const replaceCache = new WeakMap<object, WeakMap<object, any>>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a misguided point due to my limited understanding, but I have some concerns about using replaceCache as you suggested:

  1. I understand that replaceEqualDeep is a utility function that returns a copy of b, but returns a if a and b are deeply equal.

  2. From what I know, replaceEqualDeep is used when replacing existing data in a query with new data. So, I think it would be rare for existing data to change to different values multiple times.
    For example, if existing data changes from A to B as a result of queryFn, in most cases the reference to the previous A would be lost, making caching meaningless.

  3. As mentioned in point 1, replaceEqualDeep is used to replace existing data in a query with new data. Even if we receive values with the same structure through fetching in most cases, the references of the two values being compared would be different. Therefore, it seems rare to call get(b) again on the weakMap returned from replaceCache.get(a).

  4. I believe utility functions should maintain purity. If multiple queryClients are running, replaceCache operations from one queryClient might affect the behavior of another queryClient. (Of course, based on the current implementation, affecting other clients doesn't seem to cause major issues.) So, for replaceCaching, I think it would be better to create separate replaceCache objects for each queryClient or queryObserver rather than using a utility function.

If my understanding is incorrect or if you have different thoughts, please let me know your feedback :)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thoughtful feedback! You’re absolutely right about the purity concerns and global state issues.

However, I believe there’s still value here based on real usage patterns - optimistic updates, form synchronization, and incremental data changes are extremely common and would benefit from this optimization. The performance impact in issue #1633 (500ms → 50ms) demonstrates real user pain points.

Let me revise the approach: remove the global WeakMap and implement QueryClient-scoped memoization as an opt-in feature. This addresses your architectural concerns while still providing performance benefits for users who need them.

What do you think about this compromise?​​​​​​​​​​​​​​​​

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the issue you mentioned has already been resolved. The setQueryData is already using get instead of find.

(PR that removed find from setQueryData)

If test cases were included, we could verify that performance improvements can be achieved through the code in the PR.

Also, I don't think the WeakMap-based cache would work even with optimistic updates, form synchronization, and incremental data changes.

As I mentioned in my previous comment, it's hard to imagine fetching an object with the same reference value as a discarded object that has already been updated.

(Though I suppose it might be meaningful when applying the same reference-valued object created on the client to replaceEqualDeep multiple times)


export function replaceEqualDeep<T>(a: unknown, b: T): T
export function replaceEqualDeep(a: any, b: any): any {
// Early return for primitive equality (optimization for most common case)
if (a === b) {
return a
}

// Early return for non-objects to avoid unnecessary processing
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
return b
}

// Use memoization for already processed object pairs
if (replaceCache.has(a)) {
const cachedResult = replaceCache.get(a)?.get(b);
if (cachedResult !== undefined) {
return cachedResult;
}
}

const array = isPlainArray(a) && isPlainArray(b)

Expand All @@ -263,31 +280,102 @@ export function replaceEqualDeep(a: any, b: any): any {
const aSize = aItems.length
const bItems = array ? b : Object.keys(b)
const bSize = bItems.length
const copy: any = array ? [] : {}
const aItemsSet = new Set(aItems)

// Size mismatch means we need a new reference, but we should preserve original object references
// inside the array where possible
if (aSize !== bSize) {
// Create a new array or object
const copy: any = array ? new Array(bSize) : { ...b }

// For arrays with different sizes, we need to carefully handle each element
if (array) {
// For arrays, copy references from original where possible
const minLength = Math.min(aSize, bSize);

// Copy common elements, preserving original references
for (let i = 0; i < minLength; i++) {
// Use deep comparison for each element
copy[i] = replaceEqualDeep(a[i], b[i])
}

// For supersets (b is larger), copy remaining elements directly
if (bSize > aSize) {
for (let i = aSize; i < bSize; i++) {
copy[i] = b[i]
}
}
}

// Cache and return the result
if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
if (!replaceCache.has(a)) {
replaceCache.set(a, new WeakMap());
}
replaceCache.get(a)?.set(b, copy);
}

return copy
}

// Create a new copy for structural sharing
const copy: any = array ? new Array(bSize) : {}

// For objects, use Set for O(1) lookups
const aItemsSet = array ? null : new Set(aItems)

let equalItems = 0

for (let i = 0; i < bSize; i++) {
const key = array ? i : bItems[i]
if (
((!array && aItemsSet.has(key)) || array) &&
a[key] === undefined &&
b[key] === undefined
) {
copy[key] = undefined
equalItems++
} else {
copy[key] = replaceEqualDeep(a[key], b[key])
if (copy[key] === a[key] && a[key] !== undefined) {

// Process in chunks for large arrays to improve performance
const chunkSize = 1000
const processChunk = (start: number, end: number): void => {
for (let i = start; i < end && i < bSize; i++) {
const key = array ? i : bItems[i]
if (
((!array && aItemsSet?.has(key)) || array) &&
a[key] === undefined &&
b[key] === undefined
) {
copy[key] = undefined
equalItems++
} else {
// When elements are deep equal but not the same reference,
// we need to keep the original reference
const result = replaceEqualDeep(a[key], b[key])
copy[key] = result

// Check if we maintained reference equality
if (result === a[key] && a[key] !== undefined) {
equalItems++
}
}
}
}

return aSize === bSize && equalItems === aSize ? a : copy

// For large arrays, process in chunks to avoid call stack issues
if (array && bSize > chunkSize) {
for (let i = 0; i < bSize; i += chunkSize) {
processChunk(i, i + chunkSize);
}
} else {
processChunk(0, bSize);
}

// Use the previous reference only when everything is exactly equal
// This is critical for reference equality tests
const result = aSize === bSize && equalItems === aSize ? a : copy

// Store in cache for future lookups
if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
if (!replaceCache.has(a)) {
replaceCache.set(a, new WeakMap());
}
replaceCache.get(a)?.set(b, result);
}

return result
}

// For any other objects that aren't plain objects or arrays
return b
}

Expand Down