From 45d7510051cbc3238b9754eabdfe47ddef49505e Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:35:01 +0100 Subject: [PATCH] perf: use stable-hash for hashing keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using `JSON.stringify` is much slower when we only need it to create a stable value hash. Instead, we can use `stable-hash`. This is added as a devDependency so it ends up in the bundle rather than being a production dependency. On my machine, some bench results: | Task name | Latency avg (ns) | Latency med (ns) | Throughput avg (ops/s) | Throughput med (ops/s) | Samples | | -- | -- | -- | -- | -- | -- | | 'hashKey dev' | '29.05 ± 0.10%' | '41.00 ± 1.00' | '27211238 ± 0.01%' | '24390244 ± 580720' | 17211565 | | 'hashKey prod' | '942.42 ± 3.32%' | '916.00 ± 1.00' | '1096146 ± 0.01%' | '1091703 ± 1191' | 530549 | --- packages/query-core/package.json | 3 +- .../src/__tests__/hydration.test.tsx | 15 +++++--- .../__tests__/infiniteQueryBehavior.test.tsx | 7 ++-- .../query-core/src/__tests__/query.test.tsx | 3 +- .../query-core/src/__tests__/utils.test.tsx | 12 +++---- packages/query-core/src/hydration.ts | 4 +-- packages/query-core/src/query.ts | 5 +-- packages/query-core/src/utils.ts | 34 ++++++++++++------- pnpm-lock.yaml | 8 +++++ 9 files changed, 55 insertions(+), 36 deletions(-) diff --git a/packages/query-core/package.json b/packages/query-core/package.json index 1caa2e7109b..2828cf7da1a 100644 --- a/packages/query-core/package.json +++ b/packages/query-core/package.json @@ -59,6 +59,7 @@ ], "devDependencies": { "@tanstack/query-test-utils": "workspace:*", - "npm-run-all2": "^5.0.0" + "npm-run-all2": "^5.0.0", + "stable-hash": "^0.0.6" } } diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 3c61a7ba771..00b0d942fee 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -5,6 +5,7 @@ import { QueryCache } from '../queryCache' import { dehydrate, hydrate } from '../hydration' import { MutationCache } from '../mutationCache' import { executeMutation, mockOnlineManagerIsOnline } from './utils' +import { hashKey } from '../utils'; describe('dehydration and rehydration', () => { beforeEach(() => { @@ -631,6 +632,7 @@ describe('dehydration and rehydration', () => { it('should set the fetchStatus to idle when creating a query with dehydrate', async () => { const key = queryKey() + const keyHash = hashKey(key) const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) @@ -660,7 +662,7 @@ describe('dehydration and rehydration', () => { const dehydrated = dehydrate(queryClient) resolvePromise('string') expect( - dehydrated.queries.find((q) => q.queryHash === JSON.stringify(key))?.state + dehydrated.queries.find((q) => q.queryHash === keyHash)?.state .fetchStatus, ).toBe('fetching') const stringified = JSON.stringify(dehydrated) @@ -677,7 +679,9 @@ describe('dehydration and rehydration', () => { it('should dehydrate and hydrate meta for queries', async () => { const metaKey = queryKey() + const metaKeyHash = hashKey(metaKey) const noMetaKey = queryKey() + const noMetaKeyHash = hashKey(noMetaKey) const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) queryClient.prefetchQuery({ @@ -696,21 +700,21 @@ describe('dehydration and rehydration', () => { const dehydrated = dehydrate(queryClient) expect( - dehydrated.queries.find((q) => q.queryHash === JSON.stringify(metaKey)) + dehydrated.queries.find((q) => q.queryHash === metaKeyHash) ?.meta, ).toEqual({ some: 'meta', }) expect( - dehydrated.queries.find((q) => q.queryHash === JSON.stringify(noMetaKey)) + dehydrated.queries.find((q) => q.queryHash === noMetaKeyHash) ?.meta, ).toEqual(undefined) expect( Object.keys( dehydrated.queries.find( - (q) => q.queryHash === JSON.stringify(noMetaKey), + (q) => q.queryHash === noMetaKeyHash, )!, ), ).not.toEqual(expect.arrayContaining(['meta'])) @@ -796,6 +800,7 @@ describe('dehydration and rehydration', () => { it('should not change fetchStatus when updating a query with dehydrate', async () => { const key = queryKey() + const keyHash = hashKey(key) const queryClient = new QueryClient() const options = { @@ -809,7 +814,7 @@ describe('dehydration and rehydration', () => { const dehydrated = dehydrate(queryClient) expect( - dehydrated.queries.find((q) => q.queryHash === JSON.stringify(key))?.state + dehydrated.queries.find((q) => q.queryHash === keyHash)?.state .fetchStatus, ).toBe('idle') const stringified = JSON.stringify(dehydrated) diff --git a/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx b/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx index 1a4bf1bee30..fe20790eb2c 100644 --- a/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx +++ b/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx @@ -2,16 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { CancelledError, InfiniteQueryObserver, QueryClient } from '..' import { infiniteQueryBehavior } from '../infiniteQueryBehavior' -import type { InfiniteData, InfiniteQueryObserverResult, QueryCache } from '..' +import type { InfiniteData, InfiniteQueryObserverResult } from '..' describe('InfiniteQueryBehavior', () => { let queryClient: QueryClient - let queryCache: QueryCache beforeEach(() => { vi.useFakeTimers() queryClient = new QueryClient() - queryCache = queryClient.getQueryCache() queryClient.mount() }) @@ -39,10 +37,9 @@ describe('InfiniteQueryBehavior', () => { }) await vi.advanceTimersByTimeAsync(0) - const query = queryCache.find({ queryKey: key })! expect(observerResult).toMatchObject({ isError: true, - error: new Error(`Missing queryFn: '${query.queryHash}'`), + error: new Error(`Missing queryFn: '${JSON.stringify(key)}'`), }) unsubscribe() diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index ac4c10ea5b0..1f9ec5e6b3c 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -861,10 +861,9 @@ describe('query', () => { const unsubscribe = observer.subscribe(() => undefined) await vi.advanceTimersByTimeAsync(10) - const query = queryCache.find({ queryKey: key })! expect(observer.getCurrentResult()).toMatchObject({ status: 'error', - error: new Error(`Missing queryFn: '${query.queryHash}'`), + error: new Error(`Missing queryFn: '${JSON.stringify(key)}'`), }) unsubscribe() }) diff --git a/packages/query-core/src/__tests__/utils.test.tsx b/packages/query-core/src/__tests__/utils.test.tsx index c9566ddd376..112c90eaeab 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -20,7 +20,7 @@ import { skipToken, } from '../utils' import { Mutation } from '../mutation' -import type { QueryFunctionContext } from '..' +import type { QueryFunctionContext, QueryKey } from '..' describe('core/utils', () => { describe('hashQueryKeyByOptions', () => { @@ -543,9 +543,9 @@ describe('core/utils', () => { describe('hashKey', () => { it('should hash primitives correctly', () => { - expect(hashKey(['test'])).toEqual(JSON.stringify(['test'])) - expect(hashKey([123])).toEqual(JSON.stringify([123])) - expect(hashKey([null])).toEqual(JSON.stringify([null])) + expect(hashKey(['test'])).toEqual('@"test",') + expect(hashKey([123])).toEqual('@123,') + expect(hashKey([null])).toEqual('@null,') }) it('should hash objects with sorted keys consistently', () => { @@ -556,7 +556,7 @@ describe('core/utils', () => { const hash2 = hashKey(key2) expect(hash1).toEqual(hash2) - expect(hash1).toEqual(JSON.stringify([{ a: 1, b: 2 }])) + expect(hash1).toEqual('@#b:2,a:1,,') }) it('should hash arrays consistently', () => { @@ -641,7 +641,7 @@ describe('core/utils', () => { const resolved = ensureQueryFn({ queryFn: skipToken, - queryHash: '["skip"]', + queryKey: ['skip'] as QueryKey, }) expect(consoleErrorSpy).toHaveBeenCalledWith( diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index 976b5faafee..fc0acd96204 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -1,5 +1,5 @@ import { tryResolveSync } from './thenable' -import { noop } from './utils' +import { describeKey, noop } from './utils' import type { DefaultError, MutationKey, @@ -89,7 +89,7 @@ function dehydrateQuery( // If not in production, log original error before rejecting redacted error if (process.env.NODE_ENV !== 'production') { console.error( - `A query that was dehydrated as pending ended up rejecting. [${query.queryHash}]: ${error}; The error will be redacted in production builds`, + `A query that was dehydrated as pending ended up rejecting. [${describeKey(query.queryKey)}]: ${error}; The error will be redacted in production builds`, ) } return Promise.reject(new Error('redacted')) diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 62bc9a16082..5e9eb49c8fa 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -1,4 +1,5 @@ import { + describeKey, ensureQueryFn, noop, replaceData, @@ -569,10 +570,10 @@ export class Query< if (data === undefined) { if (process.env.NODE_ENV !== 'production') { console.error( - `Query data cannot be undefined. Please make sure to return a value other than undefined from your query function. Affected query key: ${this.queryHash}`, + `Query data cannot be undefined. Please make sure to return a value other than undefined from your query function. Affected query key: ${describeKey(this.queryKey)}`, ) } - throw new Error(`${this.queryHash} data is undefined`) + throw new Error(`${describeKey(this.queryKey)} data is undefined`) } this.setData(data) diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index b97b2cc5a33..f71e92c9e98 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,3 +1,4 @@ +import stableHash from 'stable-hash'; import { timeoutManager } from './timeoutManager' import type { DefaultError, @@ -230,16 +231,22 @@ export function hashQueryKeyByOptions( * Hashes the value into a stable hash. */ export function hashKey(queryKey: QueryKey | MutationKey): string { - return JSON.stringify(queryKey, (_, val) => - isPlainObject(val) - ? Object.keys(val) - .sort() - .reduce((result, key) => { - result[key] = val[key] - return result - }, {} as any) - : val, - ) + return stableHash(queryKey) +} + +/** + * Renders a key as a human readable string for logging and error messages. + * Keys are not guaranteed to be JSON serializable, so falls back to the hash. + */ +export function describeKey(key: QueryKey | MutationKey | undefined): string { + if (key === undefined) { + return 'undefined' + } + try { + return JSON.stringify(key) + } catch { + return hashKey(key) + } } /** @@ -396,7 +403,7 @@ export function replaceData< return replaceEqualDeep(prevData, data) } catch (error) { console.error( - `Structural sharing requires data to be JSON serializable. To fix this, turn off structuralSharing or return JSON-serializable data from your queryFn. [${options.queryHash}]: ${error}`, + `Structural sharing requires data to be JSON serializable. To fix this, turn off structuralSharing or return JSON-serializable data from your queryFn. [${describeKey(options.queryKey)}]: ${error}`, ) // Prevent the replaceEqualDeep from being called again down below. @@ -435,13 +442,14 @@ export function ensureQueryFn< options: { queryFn?: QueryFunction | SkipToken queryHash?: string + queryKey?: TQueryKey }, fetchOptions?: FetchOptions, ): QueryFunction { if (process.env.NODE_ENV !== 'production') { if (options.queryFn === skipToken) { console.error( - `Attempted to invoke queryFn when set to skipToken. This is likely a configuration error. Query hash: '${options.queryHash}'`, + `Attempted to invoke queryFn when set to skipToken. This is likely a configuration error. Query key: '${describeKey(options.queryKey)}'`, ) } } @@ -455,7 +463,7 @@ export function ensureQueryFn< if (!options.queryFn || options.queryFn === skipToken) { return () => - Promise.reject(new Error(`Missing queryFn: '${options.queryHash}'`)) + Promise.reject(new Error(`Missing queryFn: '${describeKey(options.queryKey)}'`)) } return options.queryFn diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24698d4fd93..0c31105884c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2716,6 +2716,9 @@ importers: npm-run-all2: specifier: ^5.0.0 version: 5.0.2 + stable-hash: + specifier: ^0.0.6 + version: 0.0.6 packages/query-devtools: devDependencies: @@ -14738,6 +14741,9 @@ packages: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} + stable-hash@0.0.6: + resolution: {integrity: sha512-0afH4mobqTybYZsXImQRLOjHV4gvOW+92HdUIax9t7a8d9v54KWykEuMVIcXhD9BCi+w3kS4x7O6fmZQ3JlG/g==} + stack-trace@1.0.0-pre2: resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==} engines: {node: '>=16'} @@ -30719,6 +30725,8 @@ snapshots: stable-hash-x@0.2.0: {} + stable-hash@0.0.6: {} + stack-trace@1.0.0-pre2: {} stack-utils@2.0.6: