From 0753acefaec2b99c07e87745ff4ecd64b48788dd Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 21 Aug 2026 09:45:25 +0200 Subject: [PATCH 01/10] serializer --- docs/framework/react/guides/query-keys.md | 31 ++ .../react/plugins/createPersister.md | 4 +- docs/framework/react/reference/useQuery.md | 4 - docs/framework/solid/reference/useQuery.md | 4 - docs/reference/MutationCache.md | 9 + docs/reference/QueryCache.md | 9 + docs/reference/QueryClient.md | 2 + .../src/__tests__/useQuery.test.tsx | 28 +- .../src/__tests__/hydration.test.tsx | 38 ++ .../__tests__/infiniteQueryObserver.test.tsx | 1 + .../src/__tests__/mutationCache.test.tsx | 44 ++ .../query-core/src/__tests__/query.test.tsx | 8 +- .../src/__tests__/queryCache.test.tsx | 42 +- .../src/__tests__/queryClient.test-d.tsx | 48 ++ .../src/__tests__/queryClient.test.tsx | 423 +++++++++++++++++- .../query-core/src/__tests__/utils.test.tsx | 150 ++++++- packages/query-core/src/index.ts | 8 +- packages/query-core/src/mutation.ts | 15 + packages/query-core/src/mutationCache.ts | 19 +- packages/query-core/src/mutationObserver.ts | 4 +- packages/query-core/src/query.ts | 5 + packages/query-core/src/queryCache.ts | 27 +- packages/query-core/src/queryClient.ts | 128 ++++-- packages/query-core/src/queryObserver.ts | 24 +- packages/query-core/src/types.ts | 51 ++- packages/query-core/src/utils.ts | 178 ++++++-- .../src/__tests__/Devtools.test.tsx | 29 +- .../src/__tests__/utils.test.ts | 17 +- .../src/__tests__/createPersister.test.ts | 139 +++++- .../src/createPersister.ts | 26 +- .../src/__tests__/useQuery.test.tsx | 28 +- .../src/__tests__/useQuery.test.tsx | 10 +- .../src/__tests__/queryClient.test.ts | 26 ++ 33 files changed, 1387 insertions(+), 192 deletions(-) diff --git a/docs/framework/react/guides/query-keys.md b/docs/framework/react/guides/query-keys.md index 7451738dae1..574363213ee 100644 --- a/docs/framework/react/guides/query-keys.md +++ b/docs/framework/react/guides/query-keys.md @@ -74,6 +74,37 @@ useQuery({ queryKey: ['todos', undefined, page, status], ...}) [//]: # 'Example4' +## Custom query key values + +If a `queryKey` contains values that need custom serialization, configure the serializer on the `QueryCache`. The serializer is used for hashing and partial matching of every query in that cache. + +The serializer must be idempotent. The same key can be serialized more than once, so serializing an already serialized value must return the same value. + +```tsx +const queryCache = new QueryCache({ + valueSerializer: (value) => { + if (value instanceof Date) { + return value.toISOString() + } + + return value + }, +}) + +const queryClient = new QueryClient({ queryCache }) +``` + +When a serializer is configured, the serialized key becomes the canonical +`queryKey`. Any key exposed by the query APIs, including the key in a query +function context, has already been serialized. Configure `MutationCache` +separately if mutation keys need custom serialization. + +If `hashFn` is provided on `QueryCache`, it receives this serialized key. The +same key is stored on cache entries and is used for matching, dehydration, and +persistence. The cache key configuration must not change while the cache +contains entries. A cache that restores dehydrated or persisted entries must +use a compatible key configuration. + ## If your query function depends on a variable, include it in your query key Since query keys uniquely describe the data they are fetching, they should include any variables you use in your query function that **change**. For example: diff --git a/docs/framework/react/plugins/createPersister.md b/docs/framework/react/plugins/createPersister.md index e98b4377064..8fcf3c030d2 100644 --- a/docs/framework/react/plugins/createPersister.md +++ b/docs/framework/react/plugins/createPersister.md @@ -126,11 +126,13 @@ The filter object supports the following properties: For this function to work, your storage must expose `entries` method that would return a `key-value tuple array`. For example `Object.entries(localStorage)` for `localStorage` or `entries` from `idb-keyval`. -### `removeQueries(filters): Promise` +### `removeQueries(queryClient: QueryClient, filters?): Promise` When using `queryClient.removeQueries`, the data remains in the persister and needs to be removed separately. This function can be used to remove queries that are currently stored by persister. +The `queryClient` is required so the persister can use its query key hashing, value serialization, and matching configuration. + The filter object supports the following properties: - `queryKey?: QueryKey` diff --git a/docs/framework/react/reference/useQuery.md b/docs/framework/react/reference/useQuery.md index 10f6df3aeeb..4f242a94a7b 100644 --- a/docs/framework/react/reference/useQuery.md +++ b/docs/framework/react/reference/useQuery.md @@ -42,7 +42,6 @@ const { meta, notifyOnChangeProps, placeholderData, - queryKeyHashFn, refetchInterval, refetchIntervalInBackground, refetchOnMount, @@ -105,9 +104,6 @@ const { - The time in milliseconds that unused/inactive cache data remains in memory. When a query's cache becomes unused or inactive, that cache data will be garbage collected after this duration. When different garbage collection times are specified, the longest one will be used. - Note: the maximum allowed time is about [24 days](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value), although it is possible to work around this limit using [timeoutManager.setTimeoutProvider](../../../reference/timeoutManager.md#timeoutmanagersettimeoutprovider). - If set to `Infinity`, will disable garbage collection -- `queryKeyHashFn: (queryKey: QueryKey) => string` - - Optional - - If specified, this function is used to hash the `queryKey` to a string. - `refetchInterval: number | false | ((query: Query) => number | false | undefined)` - Optional - If set to a number, all queries will continuously refetch at this frequency in milliseconds diff --git a/docs/framework/solid/reference/useQuery.md b/docs/framework/solid/reference/useQuery.md index 3e3378cb3e9..e00c5cbe80d 100644 --- a/docs/framework/solid/reference/useQuery.md +++ b/docs/framework/solid/reference/useQuery.md @@ -43,7 +43,6 @@ const { initialData, initialDataUpdatedAt, meta, - queryKeyHashFn, refetchInterval, refetchIntervalInBackground, refetchOnMount, @@ -242,9 +241,6 @@ function App() { - ##### `meta: Record` - Optional - If set, stores additional information on the query cache entry that can be used as needed. It will be accessible wherever the `query` is available, and is also part of the `QueryFunctionContext` provided to the `queryFn`. - - ##### `queryKeyHashFn: (queryKey: QueryKey) => string` - - Optional - - If specified, this function is used to hash the `queryKey` to a string. - ##### `refetchInterval: number | false | ((query: Query) => number | false | undefined)` - Optional - If set to a number, all queries will continuously refetch at this frequency in milliseconds diff --git a/docs/reference/MutationCache.md b/docs/reference/MutationCache.md index 89613143ce7..83fb8773412 100644 --- a/docs/reference/MutationCache.md +++ b/docs/reference/MutationCache.md @@ -11,6 +11,8 @@ The `MutationCache` is the storage for mutations. import { MutationCache } from '@tanstack/react-query' const mutationCache = new MutationCache({ + valueSerializer: (value) => + value instanceof Date ? value.toISOString() : value, onError: (error) => { console.log(error) }, @@ -28,6 +30,13 @@ Its available methods are: **Options** +- `hashFn?: (mutationKey: MutationKey) => string` + - Optional + - Hashes serialized mutation keys into cache identity strings. +- `valueSerializer?: (value: unknown) => unknown` + - Optional + - Serializes values in mutation keys before hashing and matching. It must be idempotent. The serialized key becomes the canonical mutation key exposed by mutation APIs. + - The key configuration must not change while the cache contains entries. - `onError?: (error: unknown, variables: unknown, onMutateResult: unknown, mutation: Mutation, mutationFnContext: MutationFunctionContext) => Promise | unknown` - Optional - This function will be called if some mutation encounters an error. diff --git a/docs/reference/QueryCache.md b/docs/reference/QueryCache.md index 6a341205ccf..75e27a1cebb 100644 --- a/docs/reference/QueryCache.md +++ b/docs/reference/QueryCache.md @@ -11,6 +11,8 @@ The `QueryCache` is the storage mechanism for TanStack Query. It stores all the import { QueryCache } from '@tanstack/react-query' const queryCache = new QueryCache({ + valueSerializer: (value) => + value instanceof Date ? value.toISOString() : value, onError: (error) => { console.log(error) }, @@ -35,6 +37,13 @@ Its available methods are: **Options** +- `hashFn?: (queryKey: QueryKey) => string` + - Optional + - Hashes serialized query keys into cache identity strings. +- `valueSerializer?: (value: unknown) => unknown` + - Optional + - Serializes values in query keys before hashing and matching. It must be idempotent. The serialized key becomes the canonical query key exposed by query APIs. + - The key configuration must not change while the cache contains entries. - `onError?: (error: unknown, query: Query) => void` - Optional - This function will be called if some query encounters an error. diff --git a/docs/reference/QueryClient.md b/docs/reference/QueryClient.md index 26420e20408..1c4e70848c7 100644 --- a/docs/reference/QueryClient.md +++ b/docs/reference/QueryClient.md @@ -55,9 +55,11 @@ Its available methods are: - `queryCache?: QueryCache` - Optional - The query cache this client is connected to. + - Custom query key serialization and hashing are configured when this cache is created. - `mutationCache?: MutationCache` - Optional - The mutation cache this client is connected to. + - Custom mutation key serialization and hashing are configured when this cache is created. - `defaultOptions?: DefaultOptions` - Optional - Define defaults for all queries and mutations using this queryClient. diff --git a/packages/preact-query/src/__tests__/useQuery.test.tsx b/packages/preact-query/src/__tests__/useQuery.test.tsx index 52c92ab5243..d25406fb609 100644 --- a/packages/preact-query/src/__tests__/useQuery.test.tsx +++ b/packages/preact-query/src/__tests__/useQuery.test.tsx @@ -4823,48 +4823,62 @@ describe('useQuery', () => { let hashes = 0 let renders = 0 - function queryKeyHashFn(x: any) { + function hashFn(x: any) { hashes++ return JSON.stringify(x) } + const customQueryClient = new QueryClient({ + queryCache: new QueryCache({ hashFn }), + }) + function Page() { useEffect(() => { renders++ }) - useQuery({ queryKey: key, queryFn: () => 'test', queryKeyHashFn }) + useQuery({ queryKey: key, queryFn: () => 'test' }) return null } - renderWithClient(queryClient, ) + renderWithClient(customQueryClient, ) await vi.advanceTimersByTimeAsync(0) expect(renders).toBe(hashes) + customQueryClient.clear() }) it('should hash query keys that contain bigints given a supported query hash function', async () => { const key = [queryKey(), 1n] - function queryKeyHashFn(x: any) { + function hashFn(x: any) { return JSON.stringify(x, (_, value) => { if (typeof value === 'bigint') return value.toString() return value }) } + const customQueryClient = new QueryClient({ + queryCache: new QueryCache({ + valueSerializer: (value) => + typeof value === 'bigint' ? value.toString() : value, + hashFn, + }), + }) + function Page() { - useQuery({ queryKey: key, queryFn: () => 'test', queryKeyHashFn }) + useQuery({ queryKey: key, queryFn: () => 'test' }) return null } - renderWithClient(queryClient, ) + renderWithClient(customQueryClient, ) await vi.advanceTimersByTimeAsync(0) - const query = queryClient.getQueryCache().get(queryKeyHashFn(key)) + const query = customQueryClient.getQueryCache().get(hashFn(key)) expect(query?.state.data).toBe('test') + customQueryClient.clear() }) it('should refetch when changed enabled to true in error state', async () => { diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 389226bd5b6..8005224320b 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -113,6 +113,44 @@ describe('dehydration and rehydration', () => { hydrationClient.clear() }) + it('should dehydrate the serialized cache key and serialize it again during hydration', () => { + const serializeValue = (value: unknown) => + value instanceof Date ? value.toISOString() : value + const serverClient = new QueryClient({ + queryCache: new QueryCache({ valueSerializer: serializeValue }), + mutationCache: new MutationCache({ valueSerializer: serializeValue }), + }) + const key = ['dates', new Date(0)] + + serverClient.setQueryData(key, 'data') + serverClient.getMutationCache().build(serverClient, { + mutationKey: key, + mutationFn: () => Promise.resolve('data'), + }) + + const dehydrated = dehydrate(serverClient, { + shouldDehydrateMutation: () => true, + }) + expect(dehydrated.queries[0]?.queryKey).toEqual([ + 'dates', + new Date(0).toISOString(), + ]) + expect(dehydrated.mutations[0]?.mutationKey).toEqual([ + 'dates', + new Date(0).toISOString(), + ]) + + const valueSerializer = vi.fn(serializeValue) + const client = new QueryClient({ + queryCache: new QueryCache({ valueSerializer }), + mutationCache: new MutationCache({ valueSerializer }), + }) + hydrate(client, dehydrated) + + expect(valueSerializer).toHaveBeenCalled() + expect(client.getQueryData(key)).toBe('data') + }) + it('should not dehydrate queries if dehydrateQueries is set to false', async () => { const key = queryKey() const queryCache = new QueryCache() diff --git a/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx b/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx index ad560cba801..8d94a69fb65 100644 --- a/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx +++ b/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx @@ -230,6 +230,7 @@ describe('InfiniteQueryObserver', () => { throwOnError: true, refetchOnReconnect: false, queryHash: key.join(''), + _defaulted: true, behavior: undefined, } diff --git a/packages/query-core/src/__tests__/mutationCache.test.tsx b/packages/query-core/src/__tests__/mutationCache.test.tsx index be46802a8e2..40c42803cc0 100644 --- a/packages/query-core/src/__tests__/mutationCache.test.tsx +++ b/packages/query-core/src/__tests__/mutationCache.test.tsx @@ -348,6 +348,50 @@ describe('mutationCache', () => { ).toEqual([mutation2]) expect(testCache.findAll({ mutationKey: ['unknown'] })).toEqual([]) }) + + it('should use the cache serializer once when clients share a cache', () => { + const valueSerializer = vi.fn((value: unknown) => + value instanceof Date ? value.getTime() : value, + ) + const hashFn = vi.fn((key: unknown) => JSON.stringify(key)) + const testCache = new MutationCache({ valueSerializer, hashFn }) + const stringClient = new QueryClient({ + mutationCache: testCache, + }) + const numberClient = new QueryClient({ + mutationCache: testCache, + }) + const date = new Date(0) + + testCache.build(stringClient, { + mutationKey: ['string', date], + }) + const numberMutation = testCache.build(numberClient, { + mutationKey: ['number', date], + }) + testCache.build(numberClient, { + mutationKey: ['other', date], + }) + valueSerializer.mockClear() + hashFn.mockClear() + + expect(testCache.find({ mutationKey: ['number', date] })).toBe( + numberMutation, + ) + expect(valueSerializer).toHaveBeenCalledTimes(2) + expect(hashFn).toHaveBeenCalledTimes(1) + + valueSerializer.mockClear() + hashFn.mockClear() + + expect( + testCache.findAll({ mutationKey: ['number', date], exact: true }), + ).toEqual([numberMutation]) + expect(valueSerializer).toHaveBeenCalledTimes(2) + expect(hashFn).toHaveBeenCalledTimes(1) + + stringClient.clear() + }) }) describe('garbage collection', () => { diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index bc637fcc0f8..dece8ebf157 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -13,7 +13,7 @@ import { dehydrate, hydrate, } from '..' -import { hashQueryKeyByOptions } from '../utils' +import { hashKey } from '../utils' import { mockOnlineManagerIsOnline, setIsServer } from './utils' import type { QueryFunctionContext, QueryKey, QueryObserverResult } from '..' @@ -1207,7 +1207,7 @@ describe('query', () => { const query = new Query({ client: queryClient, queryKey: key, - queryHash: hashQueryKeyByOptions(key), + queryHash: hashKey(key), }) query.addObserver(observer) @@ -1241,7 +1241,7 @@ describe('query', () => { const query = new Query({ client: queryClient, queryKey: key, - queryHash: hashQueryKeyByOptions(key), + queryHash: hashKey(key), options: { queryFn: () => 'data', initialData: initialDataFn, @@ -1324,7 +1324,7 @@ describe('query', () => { const query = new Query({ client: queryClient, queryKey: key, - queryHash: hashQueryKeyByOptions(key), + queryHash: hashKey(key), options: { queryFn }, }) diff --git a/packages/query-core/src/__tests__/queryCache.test.tsx b/packages/query-core/src/__tests__/queryCache.test.tsx index 307cb2d987c..fd84309b18d 100644 --- a/packages/query-core/src/__tests__/queryCache.test.tsx +++ b/packages/query-core/src/__tests__/queryCache.test.tsx @@ -312,6 +312,44 @@ describe('queryCache', () => { }) expect(queryCache.findAll().length).toBe(2) }) + + it('should use the cache serializer once when clients share a cache', () => { + const valueSerializer = vi.fn((value: unknown) => + value instanceof Date ? value.getTime() : value, + ) + const hashFn = vi.fn((key: unknown) => JSON.stringify(key)) + const testCache = new QueryCache({ valueSerializer, hashFn }) + const stringClient = new QueryClient({ + queryCache: testCache, + }) + const numberClient = new QueryClient({ + queryCache: testCache, + }) + const date = new Date(0) + + testCache.build(stringClient, { queryKey: ['string', date] }) + const numberQuery = testCache.build(numberClient, { + queryKey: ['number', date], + }) + testCache.build(numberClient, { queryKey: ['other', date] }) + valueSerializer.mockClear() + hashFn.mockClear() + + expect(testCache.find({ queryKey: ['number', date] })).toBe(numberQuery) + expect(valueSerializer).toHaveBeenCalledTimes(2) + expect(hashFn).toHaveBeenCalledTimes(1) + + valueSerializer.mockClear() + hashFn.mockClear() + + expect( + testCache.findAll({ queryKey: ['number', date], exact: true }), + ).toEqual([numberQuery]) + expect(valueSerializer).toHaveBeenCalledTimes(2) + expect(hashFn).toHaveBeenCalledTimes(1) + + stringClient.clear() + }) }) describe('QueryCacheConfig error callbacks', () => { @@ -362,9 +400,7 @@ describe('queryCache', () => { it('should compute queryHash from queryKey when queryHash is not provided', () => { const key = queryKey() - const query = queryCache.build(queryClient, { - queryKey: key, - }) + const query = queryCache.build(queryClient, { queryKey: key }) expect(query.queryHash).toBe(hashKey(key)) }) diff --git a/packages/query-core/src/__tests__/queryClient.test-d.tsx b/packages/query-core/src/__tests__/queryClient.test-d.tsx index 026539f26b0..79837d106dc 100644 --- a/packages/query-core/src/__tests__/queryClient.test-d.tsx +++ b/packages/query-core/src/__tests__/queryClient.test-d.tsx @@ -1,6 +1,8 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' import { queryKey } from '@tanstack/query-test-utils' import { QueryClient } from '../queryClient' +import { QueryCache } from '../queryCache' +import { MutationCache } from '../mutationCache' import { skipToken } from '../utils' import type { MutationFilters, QueryFilters, Updater } from '../utils' import type { Mutation } from '../mutation' @@ -13,12 +15,58 @@ import type { FetchInfiniteQueryOptions, InfiniteData, InfiniteQueryExecuteOptions, + MutationKey, MutationOptions, OmitKeyof, QueryKey, QueryObserverOptions, } from '../types' +describe('cache key config', () => { + it('should type the query and mutation cache configuration', () => { + const queryCache = new QueryCache({ + hashFn: (key) => { + expectTypeOf(key).toEqualTypeOf() + return 'query-hash' + }, + valueSerializer: (value) => value, + }) + const mutationCache = new MutationCache({ + hashFn: (key) => { + expectTypeOf(key).toEqualTypeOf() + return 'mutation-hash' + }, + valueSerializer: (value) => value, + }) + const queryClient = new QueryClient({ + queryCache, + mutationCache, + }) + + expectTypeOf(queryClient.getQueryCache().config).toEqualTypeOf( + queryCache.config, + ) + expectTypeOf(queryClient.getMutationCache().config).toEqualTypeOf( + mutationCache.config, + ) + queryClient.setQueryData(['key'], 'data') + expectTypeOf(queryClient).toEqualTypeOf() + }) + + it('should not allow query-level hash functions', () => { + const queryClient = new QueryClient() + + queryClient.fetchQuery({ + queryKey: ['key'], + queryFn: () => 'data', + // @ts-expect-error query key hashing is configured on QueryCache + queryKeyHashFn: () => 'hash', + }) + + expectTypeOf(queryClient).toEqualTypeOf() + }) +}) + describe('getQueryData', () => { it('should be typed if key is tagged', () => { const key = ['key'] as DataTag, number> diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index f68670104a2..ed32c17c609 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { CancelledError, + MutationCache, MutationObserver, + QueryCache, QueryClient, QueryObserver, dehydrate, @@ -16,7 +18,6 @@ import { mockOnlineManagerIsOnline } from './utils' import type { InfiniteData, Query, - QueryCache, QueryFunction, QueryObserverOptions, } from '..' @@ -75,6 +76,424 @@ describe('queryClient', () => { }) }) + describe('cache key config', () => { + it('should expose the cache key config', () => { + const hashFn = () => 'hash' + const valueSerializer = (value: unknown) => value + const configuredCache = new QueryCache({ hashFn, valueSerializer }) + const testClient = new QueryClient({ + queryCache: configuredCache, + }) + + expect(testClient.getQueryCache().config).toMatchObject({ + hashFn, + valueSerializer, + }) + }) + + it('should use separate query and mutation cache serializers', () => { + const testClient = new QueryClient({ + queryCache: new QueryCache({ + valueSerializer: (value) => + value instanceof Date ? value.toISOString() : value, + }), + mutationCache: new MutationCache({ + valueSerializer: (value) => + value instanceof Date ? value.getTime() : value, + }), + }) + const date = new Date(0) + + testClient.setQueryData(['date', date], 'data') + testClient.getMutationCache().build(testClient, { + mutationKey: ['date', date], + }) + + expect(testClient.getQueryCache().getAll()[0]?.queryKey).toEqual([ + 'date', + date.toISOString(), + ]) + expect( + testClient.getMutationCache().getAll()[0]?.options.mutationKey, + ).toEqual(['date', date.getTime()]) + }) + + it('should not traverse a cache key when no value serializer is configured', () => { + const key = ['maps', new Map([['a', 1]])] + const hashFn = vi.fn((_key: unknown) => 'custom-hash') + const testClient = new QueryClient({ + queryCache: new QueryCache({ hashFn }), + }) + + testClient.setQueryData(key, 'data') + + expect(hashFn).toHaveBeenCalledWith(key) + expect(hashFn.mock.calls[0]?.[0]).toBe(key) + expect(testClient.getQueryCache().getAll()[0]?.queryKey).toBe(key) + }) + + it('should expose the serialized query key everywhere', async () => { + const valueSerializer = (value: unknown) => + value instanceof Date ? value.toISOString() : value + const testClient = new QueryClient({ + queryCache: new QueryCache({ valueSerializer }), + }) + const key = ['dates', new Date(0)] + const serializedKey = ['dates', new Date(0).toISOString()] + const queryFn = vi.fn((_context: any) => 'data') + + expect( + testClient.defaultQueryOptions({ queryKey: key }).queryKey, + ).toEqual(serializedKey) + + await testClient.query({ queryKey: key, queryFn }) + + expect(queryFn.mock.calls[0]?.[0].queryKey).toEqual(serializedKey) + expect(testClient.getQueryCache().getAll()[0]?.queryKey).toEqual( + serializedKey, + ) + }) + + it('should serialize nested arrays and objects recursively', () => { + const serializedValues: Array = [] + const valueSerializer = (value: unknown) => { + serializedValues.push(value) + return typeof value === 'number' ? String(value) : value + } + const hashFn = vi.fn((key: unknown) => JSON.stringify(key)) + const testClient = new QueryClient({ + queryCache: new QueryCache({ valueSerializer, hashFn }), + }) + + const result = testClient.defaultQueryOptions({ + queryKey: [ + 'todos', + { + page: 1, + filters: ['active', 2], + nullable: null, + missing: undefined, + }, + ], + }) + + expect(hashFn).toHaveBeenCalledWith([ + 'todos', + { + page: '1', + filters: ['active', '2'], + nullable: null, + missing: undefined, + }, + ]) + expect(result.queryKey).toEqual([ + 'todos', + { + page: '1', + filters: ['active', '2'], + nullable: null, + missing: undefined, + }, + ]) + expect(result.queryHash).toBe( + JSON.stringify([ + 'todos', + { + page: '1', + filters: ['active', '2'], + nullable: null, + missing: undefined, + }, + ]), + ) + expect(serializedValues).toEqual([ + 'todos', + 1, + 'active', + 2, + null, + undefined, + ]) + }) + + it('should use the cache hash function for query identity', () => { + const hashFn = vi.fn((key: unknown) => + JSON.stringify(key, (_, value) => + value instanceof Map ? [...value.entries()] : value, + ), + ) + const testClient = new QueryClient({ + queryCache: new QueryCache({ hashFn }), + }) + const keyA = ['maps', new Map([['a', 1]])] + const keyB = ['maps', new Map([['a', 1]])] + + testClient.setQueryData(keyA, 'data') + + expect(testClient.getQueryData(keyB)).toBe('data') + expect(hashFn).toHaveBeenCalled() + }) + + it('should pass serialized values to the cache hash function', () => { + const valueSerializer = (value: unknown) => + value instanceof Map ? [...value.entries()] : value + const hashFn = vi.fn((key: unknown) => JSON.stringify(key)) + const testClient = new QueryClient({ + queryCache: new QueryCache({ valueSerializer, hashFn }), + }) + const key = ['maps', new Map([['a', 1]])] + + testClient.setQueryData(key, 'data') + + expect(hashFn).toHaveBeenCalledWith(['maps', [['a', 1]]]) + }) + + it('should use the query cache value serializer for hashing and matching', () => { + const valueSerializer = (value: unknown) => + value instanceof Map ? JSON.stringify([...value.entries()]) : value + const testClient = new QueryClient({ + queryCache: new QueryCache({ valueSerializer }), + }) + + const keyA = ['maps', new Map([['a', 1]])] + const keyB = ['maps', new Map([['a', 2]])] + + testClient.setQueryData(keyA, 'a') + testClient.setQueryData(keyB, 'b') + + expect(testClient.getQueryCache().getAll()).toHaveLength(2) + expect( + testClient.getQueriesData({ queryKey: ['maps', new Map([['a', 1]])] }), + ).toEqual([[['maps', '[["a",1]]'], 'a']]) + }) + + it('should use the mutation cache value serializer for matching', () => { + const valueSerializer = vi.fn((value: unknown) => + value instanceof Map ? [...value.entries()] : value, + ) + const hashFn = vi.fn((key: unknown) => JSON.stringify(key)) + const testClient = new QueryClient({ + mutationCache: new MutationCache({ valueSerializer, hashFn }), + }) + + testClient.getMutationCache().build(testClient, { + mutationKey: ['maps', new Map([['a', 1]])], + mutationFn: () => Promise.resolve(), + }) + + expect(valueSerializer).toHaveBeenCalledTimes(2) + expect(hashFn).toHaveBeenCalledTimes(1) + expect( + testClient.getMutationCache().getAll()[0]?.options.mutationKey, + ).toEqual(['maps', [['a', 1]]]) + + expect( + testClient.getMutationCache().findAll({ + mutationKey: ['maps', new Map([['a', 1]])], + }).length, + ).toBe(1) + expect( + testClient.getMutationCache().findAll({ + mutationKey: ['maps', new Map([['a', 2]])], + }).length, + ).toBe(0) + }) + + it('should use the value serializer for query cache identity', () => { + const valueSerializer = (value: unknown) => + value instanceof Map ? [...value.entries()] : value + const testClient = new QueryClient({ + queryCache: new QueryCache({ valueSerializer }), + }) + const key = ['maps', new Map([['a', 1]])] + + testClient.setQueryData(key, 'data') + + expect(testClient.getQueryData(['maps', new Map([['a', 1]])])).toBe( + 'data', + ) + expect(testClient.getQueryCache().getAll()).toHaveLength(1) + }) + + it('should use the value serializer for exact and partial query filters', () => { + const valueSerializer = (value: unknown) => + value instanceof Map ? [...value.entries()] : value + const testClient = new QueryClient({ + queryCache: new QueryCache({ valueSerializer }), + }) + const keyA = ['maps', new Map([['a', 1]])] + const keyB = ['maps', new Map([['a', 2]])] + + testClient.setQueryData(keyA, 'a') + testClient.setQueryData(keyB, 'b') + + expect( + testClient.getQueriesData({ + queryKey: ['maps', new Map([['a', 1]])], + exact: true, + }), + ).toEqual([[['maps', [['a', 1]]], 'a']]) + expect(testClient.getQueriesData({ queryKey: ['maps'] })).toEqual([ + [['maps', [['a', 1]]], 'a'], + [['maps', [['a', 2]]], 'b'], + ]) + }) + + it('should support re-serializing canonical keys in setQueriesData', () => { + const valueSerializer = vi.fn((value: unknown) => + typeof value === 'string' && !value.endsWith('!') ? `${value}!` : value, + ) + const testClient = new QueryClient({ + queryCache: new QueryCache({ valueSerializer }), + }) + + testClient.setQueryData(['key'], 'a') + valueSerializer.mockClear() + + testClient.setQueriesData({ queryKey: ['key'] }, () => 'b') + + expect(valueSerializer).toHaveBeenCalled() + expect(testClient.getQueryCache().getAll()[0]?.queryKey).toEqual(['key!']) + expect(testClient.getQueryData(['key'])).toBe('b') + }) + + it('should accept defaulted QueryCache.build options', () => { + const valueSerializer = vi.fn((value: unknown) => + value instanceof Map ? [...value.entries()] : value, + ) + const hashFn = vi.fn((key: unknown) => JSON.stringify(key)) + const testClient = new QueryClient({ + queryCache: new QueryCache({ valueSerializer, hashFn }), + }) + + const query = testClient.getQueryCache().build( + testClient, + testClient.defaultQueryOptions({ + queryKey: ['maps', new Map([['a', 1]])], + }), + ) + + expect(query.queryKey).toEqual(['maps', [['a', 1]]]) + expect(hashFn).toHaveBeenCalledTimes(1) + }) + + it('should only serialize the filter key when matching an existing query', () => { + const valueSerializer = vi.fn((value: unknown) => + value instanceof Map ? [...value.entries()] : value, + ) + const hashFn = vi.fn((value: unknown) => JSON.stringify(value)) + const testClient = new QueryClient({ + queryCache: new QueryCache({ valueSerializer, hashFn }), + }) + const key = ['maps', new Map([['a', 1]])] + const otherKey = ['maps', new Map([['a', 2]])] + + testClient.setQueryData(key, 'data') + testClient.setQueryData(otherKey, 'other data') + valueSerializer.mockClear() + hashFn.mockClear() + + expect( + testClient.getQueriesData({ + queryKey: ['maps', new Map([['a', 1]])], + exact: true, + }), + ).toEqual([[['maps', [['a', 1]]], 'data']]) + expect(valueSerializer).toHaveBeenCalledTimes(2) + expect(hashFn).toHaveBeenCalledTimes(1) + + valueSerializer.mockClear() + hashFn.mockClear() + + expect(testClient.getQueriesData({ queryKey: ['maps'] })).toEqual([ + [['maps', [['a', 1]]], 'data'], + [['maps', [['a', 2]]], 'other data'], + ]) + expect(valueSerializer).toHaveBeenCalledTimes(1) + expect(hashFn).not.toHaveBeenCalled() + }) + + it('should use the value serializer for query defaults', () => { + const valueSerializer = (value: unknown) => + value instanceof Map ? [...value.entries()] : value + const testClient = new QueryClient({ + queryCache: new QueryCache({ valueSerializer }), + }) + + testClient.setQueryDefaults(['maps', new Map([['a', 1]])], { + staleTime: 123, + }) + testClient.setQueryDefaults(['maps', new Map([['a', 1]])], { + staleTime: 456, + }) + + expect( + testClient.getQueryDefaults(['maps', new Map([['a', 1]])]), + ).toMatchObject({ staleTime: 456 }) + expect( + testClient.getQueryDefaults(['maps', new Map([['a', 2]])]), + ).toEqual({}) + }) + + it('should use the value serializer for mutation defaults and filters', () => { + const valueSerializer = (value: unknown) => + value instanceof Map ? [...value.entries()] : value + const testClient = new QueryClient({ + mutationCache: new MutationCache({ valueSerializer }), + }) + const keyA = ['maps', new Map([['a', 1]])] + const keyB = ['maps', new Map([['a', 2]])] + + testClient.setMutationDefaults(keyA, { retry: false }) + testClient.setMutationDefaults(['maps', new Map([['a', 1]])], { + retry: true, + }) + + expect( + testClient.getMutationDefaults(['maps', new Map([['a', 1]])]), + ).toMatchObject({ retry: true }) + expect( + testClient.getMutationDefaults(['maps', new Map([['a', 2]])]), + ).toEqual({}) + + testClient.getMutationCache().build(testClient, { + mutationKey: keyA, + mutationFn: () => Promise.resolve(), + }) + testClient.getMutationCache().build(testClient, { + mutationKey: keyB, + mutationFn: () => Promise.resolve(), + }) + + expect( + testClient.getMutationCache().findAll({ + mutationKey: ['maps', new Map([['a', 1]])], + exact: true, + }), + ).toHaveLength(1) + expect( + testClient.getMutationCache().findAll({ mutationKey: ['maps'] }), + ).toHaveLength(2) + }) + + it('should use the default hash for exact but not partial matching of dates', () => { + const testClient = new QueryClient() + const key = ['dates', new Date(0)] + testClient.setQueryData(key, 'data') + + expect(testClient.getQueryData(['dates', new Date(0)])).toBe('data') + expect( + testClient.getQueriesData({ + queryKey: ['dates', new Date(0)], + exact: true, + }), + ).toEqual([[key, 'data']]) + expect( + testClient.getQueriesData({ queryKey: ['dates', new Date(0)] }), + ).toEqual([]) + }) + }) + describe('setQueryDefaults', () => { it('should not trigger a fetch', () => { const key = queryKey() @@ -213,7 +632,7 @@ describe('queryClient', () => { it('should use default options', () => { const key = queryKey() const testClient = new QueryClient({ - defaultOptions: { queries: { queryKeyHashFn: () => 'someKey' } }, + queryCache: new QueryCache({ hashFn: () => 'someKey' }), }) const testCache = testClient.getQueryCache() testClient.setQueryData(key, 'data') diff --git a/packages/query-core/src/__tests__/utils.test.tsx b/packages/query-core/src/__tests__/utils.test.tsx index c9566ddd376..b526dff0486 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -1,18 +1,18 @@ import { describe, expect, it, vi } from 'vitest' import { queryKey } from '@tanstack/query-test-utils' -import { QueryClient } from '..' +import { MutationCache, QueryCache, QueryClient } from '..' import { addConsumeAwareSignal, addToEnd, addToStart, ensureQueryFn, hashKey, - hashQueryKeyByOptions, isPlainArray, isPlainObject, isValidTimeout, keepPreviousData, matchMutation, + matchQuery, partialMatchKey, replaceEqualDeep, shallowEqualObjects, @@ -23,26 +23,13 @@ import { Mutation } from '../mutation' import type { QueryFunctionContext } from '..' describe('core/utils', () => { - describe('hashQueryKeyByOptions', () => { - it('should use custom hash function when provided in options', () => { - const key = ['test', { a: 1, b: 2 }] - const customHashFn = vi.fn(() => 'custom-hash') - - const result = hashQueryKeyByOptions(key, { - queryKeyHashFn: customHashFn, - }) - - expect(customHashFn).toHaveBeenCalledWith(key) - expect(result).toEqual('custom-hash') - }) - - it('should use default hash function when no options provided', () => { - const key = ['test', { a: 1, b: 2 }] - const defaultResult = hashKey(key) - const result = hashQueryKeyByOptions(key) - - expect(result).toEqual(defaultResult) - }) + it('should return `false` for same-length objects with different keys', () => { + // Both objects have the same number of keys, but the key sets differ. + // `b` is missing on the second object and `c` is missing on the first, + // so they are not shallowly equal even though both differing values + // happen to be `undefined`. + const value = Object.create({ inherited: 1 }) + expect(shallowEqualObjects(value, value)).toBe(true) }) describe('shallowEqualObjects', () => { @@ -126,6 +113,88 @@ describe('core/utils', () => { }) describe('partialMatchKey', () => { + it('should preserve values when no serializer is provided', () => { + const date = new Date(0) + + expect(partialMatchKey([date], [date])).toBe(true) + expect(partialMatchKey([new Date(0)], [new Date(0)])).toBe(false) + expect(partialMatchKey([new Date(0)], [new Date(1000)])).toBe(false) + }) + + it('should use the value serializer for each value', () => { + const valueSerializer = (value: unknown) => + value instanceof Date + ? value.toISOString() + : value instanceof Map + ? [...value.entries()] + : value + + expect( + partialMatchKey( + [new Date(0), new Map([['a', 1]])], + [new Date(0), new Map([['a', 1]])], + valueSerializer, + ), + ).toBe(true) + expect( + partialMatchKey( + [new Date(0), new Map([['a', 1]])], + [new Date(0), new Map([['a', 2]])], + valueSerializer, + ), + ).toBe(false) + }) + + it('should serialize nested values before partially matching', () => { + const valueSerializer = (value: unknown) => + value instanceof Date + ? value.toISOString() + : value instanceof Map + ? [...value.entries()] + : value + + expect( + partialMatchKey( + [ + 'todos', + { + filters: { + date: new Date(0), + map: new Map([['a', 1]]), + status: 'open', + }, + }, + ], + [ + 'todos', + { filters: { date: new Date(0), map: new Map([['a', 1]]) } }, + ], + valueSerializer, + ), + ).toBe(true) + expect( + partialMatchKey( + [ + 'todos', + { filters: { date: new Date(0), map: new Map([['a', 1]]) } }, + ], + [ + 'todos', + { filters: { date: new Date(0), map: new Map([['a', 2]]) } }, + ], + valueSerializer, + ), + ).toBe(false) + }) + + it('should not partially match distinct non-plain values without a serializer', () => { + const mapA = new Map([['a', 1]]) + const mapB = new Map([['a', 1]]) + + expect(partialMatchKey(['maps', mapA], ['maps', mapA])).toBe(true) + expect(partialMatchKey(['maps', mapA], ['maps', mapB])).toBe(false) + }) + it('should return `true` if a includes b', () => { const a = [{ a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] }] const b = [{ a: { b: 'b' }, c: 'c', d: [] }] @@ -471,6 +540,43 @@ describe('core/utils', () => { }) expect(matchMutation(filters, mutation)).toBe(false) }) + + it('should use the mutation cache serializer', () => { + const mutationCache = new MutationCache({ + hashFn: () => 'custom-hash', + valueSerializer: (value) => + value instanceof Date ? value.toISOString() : value, + }) + const queryClient = new QueryClient({ mutationCache }) + const mutation = mutationCache.build(queryClient, { + mutationKey: ['date', new Date(0)], + }) + + expect( + matchMutation( + { mutationKey: ['date', new Date(0)], exact: true }, + mutation, + ), + ).toBe(true) + }) + }) + + describe('matchQuery', () => { + it('should use the query cache serializer', () => { + const queryCache = new QueryCache({ + hashFn: () => 'custom-hash', + valueSerializer: (value) => + value instanceof Date ? value.toISOString() : value, + }) + const queryClient = new QueryClient({ queryCache }) + const query = queryCache.build(queryClient, { + queryKey: ['date', new Date(0)], + }) + + expect( + matchQuery({ queryKey: ['date', new Date(0)], exact: true }, query), + ).toBe(true) + }) }) describe('keepPreviousData', () => { diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index a4267aabc97..09a920e4d42 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -10,13 +10,16 @@ export { } from './hydration' export { InfiniteQueryObserver } from './infiniteQueryObserver' export { MutationCache } from './mutationCache' -export type { MutationCacheNotifyEvent } from './mutationCache' +export type { + MutationCacheConfig, + MutationCacheNotifyEvent, +} from './mutationCache' export { MutationObserver } from './mutationObserver' export { defaultScheduler, notifyManager } from './notifyManager' export { onlineManager } from './onlineManager' export { QueriesObserver } from './queriesObserver' export { QueryCache } from './queryCache' -export type { QueryCacheNotifyEvent } from './queryCache' +export type { QueryCacheConfig, QueryCacheNotifyEvent } from './queryCache' export { QueryClient } from './queryClient' export { QueryObserver } from './queryObserver' export { CancelledError, isCancelledError } from './retryer' @@ -35,6 +38,7 @@ export { noop, partialMatchKey, replaceEqualDeep, + serializeCacheKey, shouldThrowError, skipToken, } from './utils' diff --git a/packages/query-core/src/mutation.ts b/packages/query-core/src/mutation.ts index 6682ce3cce4..63e1d820169 100644 --- a/packages/query-core/src/mutation.ts +++ b/packages/query-core/src/mutation.ts @@ -1,9 +1,12 @@ import { notifyManager } from './notifyManager' import { Removable } from './removable' import { createRetryer } from './retryer' +import { hashKey } from './utils' import type { + CacheKeyConfig, DefaultError, MutationFunctionContext, + MutationKey, MutationMeta, MutationOptions, MutationStatus, @@ -19,6 +22,7 @@ interface MutationConfig { client: QueryClient mutationId: number mutationCache: MutationCache + mutationHash?: string options: MutationOptions state?: MutationState } @@ -89,6 +93,7 @@ export class Mutation< > extends Removable { state: MutationState options!: MutationOptions + mutationHash?: string readonly mutationId: number #client: QueryClient @@ -106,6 +111,12 @@ export class Mutation< this.#client = config.client this.mutationId = config.mutationId this.#mutationCache = config.mutationCache + this.mutationHash = + config.mutationHash ?? + (config.options.mutationKey + ? (this.#mutationCache.config.hashFn?.(config.options.mutationKey) ?? + hashKey(config.options.mutationKey)) + : undefined) this.#observers = [] this.state = config.state || getDefaultState() @@ -113,6 +124,10 @@ export class Mutation< this.scheduleGc() } + get cacheKeyConfig(): Readonly> { + return this.#mutationCache.config + } + setOptions( options: MutationOptions, ): void { diff --git a/packages/query-core/src/mutationCache.ts b/packages/query-core/src/mutationCache.ts index d204c904e5f..5e422507154 100644 --- a/packages/query-core/src/mutationCache.ts +++ b/packages/query-core/src/mutationCache.ts @@ -1,11 +1,13 @@ import { notifyManager } from './notifyManager' import { Mutation } from './mutation' -import { matchMutation, noop } from './utils' +import { createMutationMatcher, noop } from './utils' import { Subscribable } from './subscribable' import type { MutationObserver } from './mutationObserver' import type { + CacheKeyConfig, DefaultError, MutationFunctionContext, + MutationKey, MutationOptions, NotifyEvent, } from './types' @@ -15,7 +17,7 @@ import type { MutationFilters } from './utils' // TYPES -interface MutationCacheConfig { +export interface MutationCacheConfig extends CacheKeyConfig { onError?: ( error: DefaultError, variables: unknown, @@ -95,7 +97,7 @@ export class MutationCache extends Subscribable { #scopes: Map>> #mutationId: number - constructor(public config: MutationCacheConfig = {}) { + constructor(public readonly config: MutationCacheConfig = {}) { super() this.#mutations = new Set() this.#scopes = new Map() @@ -211,13 +213,18 @@ export class MutationCache extends Subscribable { ): Mutation | undefined { const defaultedFilters = { exact: true, ...filters } - return this.getAll().find((mutation) => - matchMutation(defaultedFilters, mutation), + return this.getAll().find( + createMutationMatcher(defaultedFilters, this.config), ) as Mutation | undefined } findAll(filters: MutationFilters = {}): Array { - return this.getAll().filter((mutation) => matchMutation(filters, mutation)) + const mutations = this.getAll() + if (!Object.keys(filters).length) { + return mutations + } + + return mutations.filter(createMutationMatcher(filters, this.config)) } notify(event: MutationCacheNotifyEvent) { diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index 8164e2ad024..1c6b0a046fc 100644 --- a/packages/query-core/src/mutationObserver.ts +++ b/packages/query-core/src/mutationObserver.ts @@ -82,10 +82,12 @@ export class MutationObserver< }) } + const hashFn = this.#client.getMutationCache().config.hashFn ?? hashKey + if ( prevOptions?.mutationKey && this.options.mutationKey && - hashKey(prevOptions.mutationKey) !== hashKey(this.options.mutationKey) + hashFn(prevOptions.mutationKey) !== hashFn(this.options.mutationKey) ) { this.reset() } else if (this.#currentMutation?.state.status === 'pending') { diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 245ec283781..c6c765f016b 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -14,6 +14,7 @@ import { infiniteQueryBehavior } from './infiniteQueryBehavior' import type { QueryCache } from './queryCache' import type { QueryClient } from './queryClient' import type { + CacheKeyConfig, CancelOptions, DefaultError, FetchStatus, @@ -192,6 +193,10 @@ export class Query< return this.options.meta } + get cacheKeyConfig(): Readonly> { + return this.#cache.config + } + get queryType() { return this.#queryType } diff --git a/packages/query-core/src/queryCache.ts b/packages/query-core/src/queryCache.ts index dd7123eaac8..2bc37177565 100644 --- a/packages/query-core/src/queryCache.ts +++ b/packages/query-core/src/queryCache.ts @@ -1,10 +1,11 @@ -import { hashQueryKeyByOptions, matchQuery } from './utils' +import { createQueryMatcher } from './utils' import { Query } from './query' import { notifyManager } from './notifyManager' import { Subscribable } from './subscribable' import type { QueryFilters } from './utils' import type { Action, QueryState } from './query' import type { + CacheKeyConfig, DefaultError, NotifyEvent, QueryKey, @@ -16,7 +17,7 @@ import type { QueryObserver } from './queryObserver' // TYPES -interface QueryCacheConfig { +export interface QueryCacheConfig extends CacheKeyConfig { onError?: ( error: DefaultError, query: Query, @@ -92,7 +93,7 @@ export interface QueryStore { export class QueryCache extends Subscribable { #queries: QueryStore - constructor(public config: QueryCacheConfig = {}) { + constructor(public readonly config: QueryCacheConfig = {}) { super() this.#queries = new Map() } @@ -110,9 +111,9 @@ export class QueryCache extends Subscribable { >, state?: QueryState, ): Query { - const queryKey = options.queryKey - const queryHash = - options.queryHash ?? hashQueryKeyByOptions(queryKey, options) + const defaultedOptions = client.defaultQueryOptions(options) + const queryKey = defaultedOptions.queryKey + const queryHash = defaultedOptions.queryHash let query = this.get(queryHash) if (!query) { @@ -120,7 +121,7 @@ export class QueryCache extends Subscribable { client, queryKey, queryHash, - options: client.defaultQueryOptions(options), + options: defaultedOptions, state, defaultOptions: client.getQueryDefaults(queryKey), }) @@ -185,16 +186,18 @@ export class QueryCache extends Subscribable { ): Query | undefined { const defaultedFilters = { exact: true, ...filters } - return this.getAll().find((query) => - matchQuery(defaultedFilters, query), + return this.getAll().find( + createQueryMatcher(defaultedFilters, this.config), ) as Query | undefined } findAll(filters: QueryFilters = {}): Array { const queries = this.getAll() - return Object.keys(filters).length > 0 - ? queries.filter((query) => matchQuery(filters, query)) - : queries + if (!Object.keys(filters).length) { + return queries + } + + return queries.filter(createQueryMatcher(filters, this.config)) } notify(event: QueryCacheNotifyEvent): void { diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index be8a01216a3..1f0de4975af 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -1,10 +1,10 @@ import { functionalUpdate, hashKey, - hashQueryKeyByOptions, noop, partialMatchKey, resolveStaleTime, + serializeCacheKey, skipToken, } from './utils' import { QueryCache } from './queryCache' @@ -12,7 +12,10 @@ import { MutationCache } from './mutationCache' import { focusManager } from './focusManager' import { onlineManager } from './onlineManager' import { notifyManager } from './notifyManager' +import type { MutationFilters, QueryFilters, Updater } from './utils' import type { + CacheKey, + CacheKeyConfig, CancelOptions, DefaultError, DefaultOptions, @@ -42,20 +45,53 @@ import type { SetDataOptions, } from './types' import type { QueryState } from './query' -import type { MutationFilters, QueryFilters, Updater } from './utils' // TYPES interface QueryDefaults { - queryKey: QueryKey + cacheKey: CacheKey defaultOptions: OmitKeyof, 'queryKey'> } interface MutationDefaults { - mutationKey: MutationKey + cacheKey: CacheKey defaultOptions: MutationOptions } +function mergeCacheKeyDefaults( + defaults: Iterable<{ cacheKey: CacheKey; defaultOptions: TOptions }>, + cacheKey: CacheKey, +): TOptions { + const result = {} as TOptions + + for (const cacheKeyDefault of defaults) { + if (partialMatchKey(cacheKey, cacheKeyDefault.cacheKey)) { + Object.assign(result, cacheKeyDefault.defaultOptions) + } + } + + return result +} + +function serializeAndHashCacheKey( + cacheKey: TCacheKey, + config: Readonly>, + queryHash?: string, +): { cacheKey: CacheKey; queryHash: string } { + const serializedCacheKey = serializeCacheKey( + cacheKey, + config.valueSerializer, + ) as TCacheKey + + return { + cacheKey: serializedCacheKey, + queryHash: + queryHash ?? + config.hashFn?.(serializedCacheKey) ?? + hashKey(serializedCacheKey), + } +} + // CLASS export class QueryClient { @@ -560,8 +596,12 @@ export class QueryClient { > >, ): void { - this.#queryDefaults.set(hashKey(queryKey), { + const { cacheKey, queryHash } = serializeAndHashCacheKey( queryKey, + this.#queryCache.config, + ) + this.#queryDefaults.set(queryHash, { + cacheKey, defaultOptions: options, }) } @@ -569,19 +609,11 @@ export class QueryClient { getQueryDefaults( queryKey: QueryKey, ): OmitKeyof, 'queryKey'> { - const defaults = [...this.#queryDefaults.values()] - - const result: OmitKeyof< - QueryObserverOptions, - 'queryKey' - > = {} - - defaults.forEach((queryDefault) => { - if (partialMatchKey(queryKey, queryDefault.queryKey)) { - Object.assign(result, queryDefault.defaultOptions) - } - }) - return result + const cacheKey = serializeCacheKey( + queryKey, + this.#queryCache.config.valueSerializer, + ) + return mergeCacheKeyDefaults(this.#queryDefaults.values(), cacheKey) } setMutationDefaults< @@ -596,8 +628,12 @@ export class QueryClient { 'mutationKey' >, ): void { - this.#mutationDefaults.set(hashKey(mutationKey), { + const { cacheKey, queryHash } = serializeAndHashCacheKey( mutationKey, + this.#mutationCache.config, + ) + this.#mutationDefaults.set(queryHash, { + cacheKey, defaultOptions: options, }) } @@ -605,20 +641,11 @@ export class QueryClient { getMutationDefaults( mutationKey: MutationKey, ): OmitKeyof, 'mutationKey'> { - const defaults = [...this.#mutationDefaults.values()] - - const result: OmitKeyof< - MutationObserverOptions, - 'mutationKey' - > = {} - - defaults.forEach((queryDefault) => { - if (partialMatchKey(mutationKey, queryDefault.mutationKey)) { - Object.assign(result, queryDefault.defaultOptions) - } - }) - - return result + const cacheKey = serializeCacheKey( + mutationKey, + this.#mutationCache.config.valueSerializer, + ) + return mergeCacheKeyDefaults(this.#mutationDefaults.values(), cacheKey) } defaultQueryOptions< @@ -662,19 +689,25 @@ export class QueryClient { > } + const { cacheKey: serializedCacheKey, queryHash } = + serializeAndHashCacheKey( + options.queryKey, + this.#queryCache.config, + options.queryHash, + ) + const defaultedOptions = { ...this.#defaultOptions.queries, - ...this.getQueryDefaults(options.queryKey), + ...mergeCacheKeyDefaults( + this.#queryDefaults.values(), + serializedCacheKey, + ), ...options, + queryKey: serializedCacheKey as TQueryKey, _defaulted: true, } - if (!defaultedOptions.queryHash) { - defaultedOptions.queryHash = hashQueryKeyByOptions( - defaultedOptions.queryKey, - defaultedOptions, - ) - } + defaultedOptions.queryHash = queryHash // dependent default values if (defaultedOptions.refetchOnReconnect === undefined) { @@ -708,11 +741,24 @@ export class QueryClient { if (options?._defaulted) { return options } + const serializedCacheKey = options?.mutationKey + ? serializeCacheKey( + options.mutationKey, + this.#mutationCache.config.valueSerializer, + ) + : undefined + return { ...this.#defaultOptions.mutations, ...(options?.mutationKey && - this.getMutationDefaults(options.mutationKey)), + mergeCacheKeyDefaults( + this.#mutationDefaults.values(), + serializedCacheKey!, + )), ...options, + ...(serializedCacheKey && { + mutationKey: serializedCacheKey, + }), _defaulted: true, } as T } diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 36ab6e9efe4..0df37a285b0 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -43,6 +43,13 @@ export class QueryObserver< TQueryKey extends QueryKey = QueryKey, > extends Subscribable> { #client: QueryClient + public options!: DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > #currentQuery: Query = undefined! #currentQueryInitialState: QueryState = undefined! #currentResult: QueryObserverResult = undefined! @@ -67,7 +74,7 @@ export class QueryObserver< constructor( client: QueryClient, - public options: QueryObserverOptions< + options: QueryObserverOptions< TQueryFnData, TError, TData, @@ -79,6 +86,13 @@ export class QueryObserver< this.#client = client this.#selectError = null + this.options = options as DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > this.bindMethods() this.setOptions(options) @@ -140,7 +154,13 @@ export class QueryObserver< TQueryKey >, ): void { - const prevOptions = this.options + const prevOptions = this.options as QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > const prevQuery = this.#currentQuery this.options = this.#client.defaultQueryOptions(options) diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index e0220c5978d..77ab39bb782 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -189,10 +189,32 @@ export type QueriesPlaceholderDataFunction = ( previousQuery: undefined, ) => TQueryData | undefined -export type QueryKeyHashFunction = ( +export type QueryKeyHashFunction = ( queryKey: TQueryKey, ) => string +export type CacheKeyHashFunction = ( + cacheKey: TCacheKey, +) => string + +export type CacheKeyValueSerializer = (value: unknown) => unknown + +/** + * Serialization and hashing configuration used by a cache. + */ +export interface CacheKeyConfig { + /** + * Hashes the serialized cache key into the cache identity string. + */ + readonly hashFn?: CacheKeyHashFunction + /** + * Serializes cache key values. The serializer must be idempotent because a + * key can be serialized more than once. The serialized key is the canonical + * key used for hashing, matching, cache entries, and operation contexts. + */ + readonly valueSerializer?: CacheKeyValueSerializer +} + export type GetPreviousPageParamFunction = ( firstPage: TQueryFnData, allPages: Array, @@ -255,7 +277,6 @@ export interface QueryOptions< persister?: QueryPersister, TPageParam> queryHash?: string queryKey?: TQueryKey - queryKeyHashFn?: QueryKeyHashFunction initialData?: TData | InitialDataFunction initialDataUpdatedAt?: number | (() => number | undefined) behavior?: QueryBehavior @@ -452,7 +473,22 @@ export type DefaultedQueryObserverOptions< > = WithRequired< QueryObserverOptions, 'throwOnError' | 'refetchOnReconnect' | 'queryHash' -> +> & { + _defaulted: true +} + +export type DefaultedQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, +> = WithRequired< + QueryOptions, + 'queryKey' | 'queryHash' +> & { + _defaulted: true +} export interface InfiniteQueryObserverOptions< TQueryFnData = unknown, @@ -487,7 +523,9 @@ export type DefaultedInfiniteQueryObserverOptions< TPageParam >, 'throwOnError' | 'refetchOnReconnect' | 'queryHash' -> +> & { + _defaulted: true +} export interface QueryExecuteOptions< TQueryFnData = unknown, @@ -1075,6 +1113,11 @@ export type MutationKey = Register extends { : ReadonlyArray : ReadonlyArray +/** + * A key used to identify an entry in either cache. + */ +export type CacheKey = QueryKey | MutationKey + export type MutationStatus = 'idle' | 'pending' | 'success' | 'error' export type MutationScope = { diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index f442ab86fdc..550f5b72856 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,5 +1,8 @@ import { timeoutManager } from './timeoutManager' import type { + CacheKey, + CacheKeyConfig, + CacheKeyValueSerializer, DefaultError, FetchStatus, MutationKey, @@ -144,23 +147,31 @@ export function matchQuery( filters: QueryFilters, query: Query, ): boolean { - const { - type = 'all', - exact, - fetchStatus, - predicate, - queryKey, - stale, - } = filters - - if (queryKey) { - if (exact) { - if (query.queryHash !== hashQueryKeyByOptions(queryKey, query.options)) { - return false - } - } else if (!partialMatchKey(query.queryKey, queryKey)) { - return false - } + return createQueryMatcher(filters, query.cacheKeyConfig)(query) +} + +export function createQueryMatcher( + filters: QueryFilters, + config: Readonly>, +): (query: Query) => boolean { + const cacheKeyMatcher = createCacheKeyMatcher( + filters.queryKey, + filters.exact, + config, + ) + + return (query) => matchQueryWithMatcher(filters, query, cacheKeyMatcher) +} + +function matchQueryWithMatcher( + filters: QueryFilters, + query: Query, + cacheKeyMatcher?: CacheKeyMatcher, +): boolean { + const { type = 'all', fetchStatus, predicate, queryKey, stale } = filters + + if (queryKey && !cacheKeyMatcher?.(query.queryKey, query.queryHash)) { + return false } if (type !== 'all') { @@ -192,16 +203,34 @@ export function matchMutation( filters: MutationFilters, mutation: Mutation, ): boolean { - const { exact, status, predicate, mutationKey } = filters + return createMutationMatcher(filters, mutation.cacheKeyConfig)(mutation) +} + +export function createMutationMatcher( + filters: MutationFilters, + config: Readonly>, +): (mutation: Mutation) => boolean { + const cacheKeyMatcher = createCacheKeyMatcher( + filters.mutationKey, + filters.exact, + config, + ) + + return (mutation) => + matchMutationWithMatcher(filters, mutation, cacheKeyMatcher) +} + +function matchMutationWithMatcher( + filters: MutationFilters, + mutation: Mutation, + cacheKeyMatcher?: CacheKeyMatcher, +): boolean { + const { status, predicate, mutationKey } = filters if (mutationKey) { - if (!mutation.options.mutationKey) { - return false - } - if (exact) { - if (hashKey(mutation.options.mutationKey) !== hashKey(mutationKey)) { - return false - } - } else if (!partialMatchKey(mutation.options.mutationKey, mutationKey)) { + if ( + !mutation.options.mutationKey || + !cacheKeyMatcher?.(mutation.options.mutationKey, mutation.mutationHash) + ) { return false } } @@ -217,20 +246,17 @@ export function matchMutation( return true } -export function hashQueryKeyByOptions( - queryKey: TQueryKey, - options?: Pick, 'queryKeyHashFn'>, -): string { - const hashFn = options?.queryKeyHashFn || hashKey - return hashFn(queryKey) -} +type CacheKeyMatcher = ( + cacheKey: CacheKey, + cacheHash: string | undefined, +) => boolean /** - * Default query & mutation keys hash function. + * Default cache key hash function. * Hashes the value into a stable hash. */ -export function hashKey(queryKey: QueryKey | MutationKey): string { - return JSON.stringify(queryKey, (_, val) => +export function hashKey(cacheKey: CacheKey): string { + return JSON.stringify(cacheKey, (_, val) => isPlainObject(val) ? Object.keys(val) .sort() @@ -242,11 +268,79 @@ export function hashKey(queryKey: QueryKey | MutationKey): string { ) } +function serializeCacheKeyValue( + value: unknown, + serializer: CacheKeyValueSerializer | undefined, +): unknown { + if (Array.isArray(value)) { + return value.map((item) => serializeCacheKeyValue(item, serializer)) + } + + if (isPlainObject(value)) { + const result: Record = {} + for (const key of Object.keys(value)) { + result[key] = serializeCacheKeyValue(value[key], serializer) + } + return result + } + + return serializer ? serializer(value) : value +} + +export function serializeCacheKey( + key: CacheKey, + serializer: CacheKeyValueSerializer | undefined, +): CacheKey { + if (!serializer) { + return key + } + + return serializeCacheKeyValue(key, serializer) as CacheKey +} + +function createCacheKeyMatcher( + cacheKey: TCacheKey | undefined, + exact: boolean | undefined, + config: Readonly>, +): CacheKeyMatcher | undefined { + if (!cacheKey) { + return undefined + } + + const serializedKey = serializeCacheKey( + cacheKey, + config.valueSerializer, + ) as TCacheKey + + if (exact) { + const hash = config.hashFn?.(serializedKey) ?? hashKey(serializedKey) + + return (_key, cacheHash) => cacheHash === hash + } + + return (key) => partialMatchKey(key, serializedKey) +} + /** * 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: CacheKey, + b: CacheKey, + valueSerializer?: CacheKeyValueSerializer, +): boolean +export function partialMatchKey( + a: any, + b: any, + valueSerializer?: CacheKeyValueSerializer, +): boolean { + a = serializeCacheKey(a, valueSerializer) + b = serializeCacheKey(b, valueSerializer) + + return partialMatchKeyImpl(a, b) +} + +function partialMatchKeyImpl(a: any, b: any): boolean { if (a === b) { return true } @@ -258,16 +352,20 @@ export function partialMatchKey(a: any, b: any): boolean { if (a && b && typeof a === 'object' && typeof b === 'object') { if (Array.isArray(a) && Array.isArray(b)) { for (let i = 0; i < b.length; i++) { - if (!partialMatchKey(a[i], b[i])) { + if (!partialMatchKeyImpl(a[i], b[i])) { return false } } return true } + if (!isPlainObject(a) || !isPlainObject(b)) { + return false + } + const bKeys = Object.keys(b) for (const key of bKeys) { - if (!partialMatchKey(a[key], b[key])) { + if (!partialMatchKeyImpl(a[key], b[key])) { return false } } diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index f73024502c6..b1876d00131 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + QueryCache, QueryClient, QueryObserver, dehydrate, @@ -318,10 +319,14 @@ describe('Devtools', () => { }) it('should render a query row when a hydrated query uses a custom hash function', async () => { + queryClient = new QueryClient({ + queryCache: new QueryCache({ + hashFn: () => 'custom-posts-hash', + }), + }) queryClient.fetchQuery({ queryKey: ['posts'], queryFn: () => [{ id: 1 }], - queryKeyHashFn: () => 'custom-posts-hash', }) await vi.advanceTimersByTimeAsync(0) const dehydratedState = dehydrate(queryClient) @@ -384,10 +389,13 @@ describe('Devtools', () => { describe('disabled and static queries', () => { it('should mark a disabled query in the row label', () => { - const observer = queryClient.getQueryCache().build(queryClient, { - queryKey: ['disabled-q'], - queryFn: () => 'x', - }) + const observer = queryClient.getQueryCache().build( + queryClient, + queryClient.defaultQueryOptions({ + queryKey: ['disabled-q'], + queryFn: () => 'x', + }), + ) observer.setOptions({ ...observer.options, enabled: false, @@ -399,10 +407,13 @@ describe('Devtools', () => { }) it('should render a "static" indicator for a query with "staleTime: \'static\'"', () => { - const query = queryClient.getQueryCache().build(queryClient, { - queryKey: ['static-q'], - queryFn: () => 'x', - }) + const query = queryClient.getQueryCache().build( + queryClient, + queryClient.defaultQueryOptions({ + queryKey: ['static-q'], + queryFn: () => 'x', + }), + ) const observer = new QueryObserver(queryClient, { queryKey: ['static-q'], queryFn: () => 'x', diff --git a/packages/query-devtools/src/__tests__/utils.test.ts b/packages/query-devtools/src/__tests__/utils.test.ts index 496594d63cd..409115a9b2c 100644 --- a/packages/query-devtools/src/__tests__/utils.test.ts +++ b/packages/query-devtools/src/__tests__/utils.test.ts @@ -1077,7 +1077,9 @@ describe('Utils tests', () => { queryKey: ReadonlyArray, state?: Partial, ): Query { - const query = queryClient.getQueryCache().build(queryClient, { queryKey }) + const query = queryClient + .getQueryCache() + .build(queryClient, queryClient.defaultQueryOptions({ queryKey })) if (state) { query.setState(state) } @@ -1449,7 +1451,9 @@ describe('Utils tests', () => { queryKey: QueryKey, state?: Partial, ): Query { - const query = queryClient.getQueryCache().build(queryClient, { queryKey }) + const query = queryClient + .getQueryCache() + .build(queryClient, queryClient.defaultQueryOptions({ queryKey })) if (state) { query.setState(state) } @@ -1561,9 +1565,12 @@ describe('Utils tests', () => { let queryClient: QueryClient function makeState(fetchStatus: FetchStatus): Query['state'] { - const query = queryClient.getQueryCache().build(queryClient, { - queryKey: [fetchStatus], - }) + const query = queryClient + .getQueryCache() + .build( + queryClient, + queryClient.defaultQueryOptions({ queryKey: [fetchStatus] }), + ) query.setState({ fetchStatus }) return query.state } diff --git a/packages/query-persist-client-core/src/__tests__/createPersister.test.ts b/packages/query-persist-client-core/src/__tests__/createPersister.test.ts index ca0a8aeddbf..f4f3c1e2319 100644 --- a/packages/query-persist-client-core/src/__tests__/createPersister.test.ts +++ b/packages/query-persist-client-core/src/__tests__/createPersister.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { Query, QueryClient, hashKey } from '@tanstack/query-core' +import { Query, QueryCache, QueryClient, hashKey } from '@tanstack/query-core' import { PERSISTER_KEY_PREFIX, experimental_createQueryPersister, @@ -592,6 +592,68 @@ describe('createPersister', () => { }) describe('restoreQueries', () => { + it('should use the query cache hash function for exact filters', async () => { + const storage = getFreshStorage() + const client = new QueryClient({ + queryCache: new QueryCache({ hashFn: () => 'custom-hash' }), + }) + const queryKey = ['foo'] + const persister = experimental_createQueryPersister({ storage }) + client.setQueryData(queryKey, 'foo') + + await persister.persistQueryByKey(queryKey, client) + client.clear() + + await persister.restoreQueries(client, { queryKey, exact: true }) + + expect(client.getQueryData(queryKey)).toBe('foo') + }) + + it('should use the query cache value serializer for exact filters', async () => { + const storage = getFreshStorage() + const client = new QueryClient({ + queryCache: new QueryCache({ + valueSerializer: (value) => + value instanceof Date ? value.toISOString() : value, + }), + }) + const persister = experimental_createQueryPersister({ storage }) + const queryKey = ['dates', new Date(0)] + client.setQueryData(queryKey, 'foo') + + await persister.persistQueryByKey(queryKey, client) + client.clear() + + await persister.restoreQueries(client, { + queryKey: ['dates', new Date(0)], + exact: true, + }) + + expect(client.getQueryData(queryKey)).toBe('foo') + }) + + it('should use the query cache value serializer for partial filters', async () => { + const storage = getFreshStorage() + const client = new QueryClient({ + queryCache: new QueryCache({ + valueSerializer: (value) => + value instanceof Date ? value.toISOString() : value, + }), + }) + const persister = experimental_createQueryPersister({ storage }) + const queryKey = ['dates', new Date(0)] + client.setQueryData(queryKey, 'foo') + + await persister.persistQueryByKey(queryKey, client) + client.clear() + + await persister.restoreQueries(client, { + queryKey: ['dates', new Date(0)], + }) + + expect(client.getQueryData(queryKey)).toBe('foo') + }) + it('should properly clean storage from busted entries', async () => { const storage = getFreshStorage() const { persister, client, query, queryKey } = setupPersister(['foo'], { @@ -737,6 +799,62 @@ describe('createPersister', () => { }) describe('removeQueries', () => { + it('should use the query cache hash function for exact filters', async () => { + const storage = getFreshStorage() + const client = new QueryClient({ + queryCache: new QueryCache({ hashFn: () => 'custom-hash' }), + }) + const queryKey = ['foo'] + const persister = experimental_createQueryPersister({ storage }) + client.setQueryData(queryKey, 'foo') + + await persister.persistQueryByKey(queryKey, client) + await persister.removeQueries(client, { queryKey, exact: true }) + + expect(await storage.entries()).toHaveLength(0) + }) + + it('should use the query cache value serializer for exact filters', async () => { + const storage = getFreshStorage() + const client = new QueryClient({ + queryCache: new QueryCache({ + valueSerializer: (value) => + value instanceof Date ? value.toISOString() : value, + }), + }) + const persister = experimental_createQueryPersister({ storage }) + const queryKey = ['dates', new Date(0)] + client.setQueryData(queryKey, 'foo') + + await persister.persistQueryByKey(queryKey, client) + await persister.removeQueries(client, { + queryKey: ['dates', new Date(0)], + exact: true, + }) + + expect(await storage.entries()).toHaveLength(0) + }) + + it('should use the query cache value serializer for partial filters', async () => { + const storage = getFreshStorage() + const client = new QueryClient({ + queryCache: new QueryCache({ + valueSerializer: (value) => + value instanceof Date ? value.toISOString() : value, + }), + }) + const persister = experimental_createQueryPersister({ storage }) + const queryKey = ['dates', new Date(0)] + client.setQueryData(queryKey, 'foo') + + await persister.persistQueryByKey(queryKey, client) + await persister.removeQueries(client, { + queryKey: ['dates', new Date(0)], + }) + + expect(await storage.entries()).toHaveLength(0) + }) + it('should remove restore queries from storage without filters', async () => { const storage = getFreshStorage() const { persister, client, queryKey } = setupPersister(['foo'], { @@ -747,7 +865,7 @@ describe('createPersister', () => { await persister.persistQueryByKey(queryKey, client) expect(await storage.entries()).toHaveLength(1) - await persister.removeQueries() + await persister.removeQueries(client) expect(await storage.entries()).toHaveLength(0) }) @@ -761,7 +879,7 @@ describe('createPersister', () => { await persister.persistQueryByKey(queryKey, client) expect(await storage.entries()).toHaveLength(1) - await persister.removeQueries({ queryKey }) + await persister.removeQueries(client, { queryKey }) expect(await storage.entries()).toHaveLength(0) }) @@ -775,7 +893,7 @@ describe('createPersister', () => { await persister.persistQueryByKey(queryKey, client) expect(await storage.entries()).toHaveLength(1) - await persister.removeQueries({ queryKey: ['bar'] }) + await persister.removeQueries(client, { queryKey: ['bar'] }) expect(await storage.entries()).toHaveLength(1) }) @@ -789,7 +907,7 @@ describe('createPersister', () => { await persister.persistQueryByKey(queryKey, client) expect(await storage.entries()).toHaveLength(1) - await persister.removeQueries({ queryKey: ['foo'] }) + await persister.removeQueries(client, { queryKey: ['foo'] }) expect(await storage.entries()).toHaveLength(0) }) @@ -803,7 +921,10 @@ describe('createPersister', () => { await persister.persistQueryByKey(queryKey, client) expect(await storage.entries()).toHaveLength(1) - await persister.removeQueries({ queryKey: ['foo'], exact: true }) + await persister.removeQueries(client, { + queryKey: ['foo'], + exact: true, + }) expect(await storage.entries()).toHaveLength(1) }) @@ -817,7 +938,7 @@ describe('createPersister', () => { await persister.persistQueryByKey(queryKey, client) expect(await storage.entries()).toHaveLength(1) - await persister.removeQueries({ + await persister.removeQueries(client, { queryKey: queryKey, exact: true, }) @@ -826,12 +947,12 @@ describe('createPersister', () => { it('should remove entries that cannot be deserialized', async () => { const storage = getFreshStorage() - const { persister } = setupPersister(['foo'], { storage }) + const { persister, client } = setupPersister(['foo'], { storage }) await storage.setItem(`${PERSISTER_KEY_PREFIX}-["foo"]`, 'not-json{') expect(await storage.entries()).toHaveLength(1) - await persister.removeQueries({ queryKey: ['foo'] }) + await persister.removeQueries(client, { queryKey: ['foo'] }) expect(await storage.entries()).toHaveLength(0) }) diff --git a/packages/query-persist-client-core/src/createPersister.ts b/packages/query-persist-client-core/src/createPersister.ts index 16fa8c43104..d8bc57659db 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -3,6 +3,7 @@ import { matchQuery, notifyManager, partialMatchKey, + serializeCacheKey, } from '@tanstack/query-core' import type { Query, @@ -275,6 +276,14 @@ export function experimental_createQueryPersister({ filters: Pick = {}, ): Promise { const { exact, queryKey } = filters + const config = queryClient.getQueryCache().config + const cacheKey = queryKey + ? serializeCacheKey(queryKey, config.valueSerializer) + : undefined + const cacheHash = + cacheKey && exact + ? (config.hashFn?.(cacheKey) ?? hashKey(cacheKey)) + : undefined if (storage?.entries) { const storageKeyPrefix = `${prefix}-` @@ -295,10 +304,10 @@ export function experimental_createQueryPersister({ if (queryKey) { if (exact) { - if (persistedQuery.queryHash !== hashKey(queryKey)) { + if (persistedQuery.queryHash !== cacheHash) { continue } - } else if (!partialMatchKey(persistedQuery.queryKey, queryKey)) { + } else if (!partialMatchKey(persistedQuery.queryKey, cacheKey!)) { continue } } @@ -320,9 +329,18 @@ export function experimental_createQueryPersister({ } async function removeQueries( + queryClient: QueryClient, filters: Pick = {}, ): Promise { const { exact, queryKey } = filters + const config = queryClient.getQueryCache().config + const cacheKey = queryKey + ? serializeCacheKey(queryKey, config.valueSerializer) + : undefined + const cacheHash = + cacheKey && exact + ? (config.hashFn?.(cacheKey) ?? hashKey(cacheKey)) + : undefined if (storage?.entries) { const entries = await storage.entries() @@ -343,10 +361,10 @@ export function experimental_createQueryPersister({ } if (exact) { - if (persistedQuery.queryHash !== hashKey(queryKey)) { + if (persistedQuery.queryHash !== cacheHash) { continue } - } else if (!partialMatchKey(persistedQuery.queryKey, queryKey)) { + } else if (!partialMatchKey(persistedQuery.queryKey, cacheKey!)) { continue } diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index 702d9a3ca4e..3c04da633ff 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -4817,48 +4817,62 @@ describe('useQuery', () => { let hashes = 0 let renders = 0 - function queryKeyHashFn(x: any) { + function hashFn(x: any) { hashes++ return JSON.stringify(x) } + const customQueryClient = new QueryClient({ + queryCache: new QueryCache({ hashFn }), + }) + function Page() { React.useEffect(() => { renders++ }) - useQuery({ queryKey: key, queryFn: () => 'test', queryKeyHashFn }) + useQuery({ queryKey: key, queryFn: () => 'test' }) return null } - renderWithClient(queryClient, ) + renderWithClient(customQueryClient, ) await vi.advanceTimersByTimeAsync(0) expect(renders).toBe(hashes) + customQueryClient.clear() }) it('should hash query keys that contain bigints given a supported query hash function', async () => { const key = [queryKey(), 1n] - function queryKeyHashFn(x: any) { + function hashFn(x: any) { return JSON.stringify(x, (_, value) => { if (typeof value === 'bigint') return value.toString() return value }) } + const customQueryClient = new QueryClient({ + queryCache: new QueryCache({ + valueSerializer: (value) => + typeof value === 'bigint' ? value.toString() : value, + hashFn, + }), + }) + function Page() { - useQuery({ queryKey: key, queryFn: () => 'test', queryKeyHashFn }) + useQuery({ queryKey: key, queryFn: () => 'test' }) return null } - renderWithClient(queryClient, ) + renderWithClient(customQueryClient, ) await vi.advanceTimersByTimeAsync(0) - const query = queryClient.getQueryCache().get(queryKeyHashFn(key)) + const query = customQueryClient.getQueryCache().get(hashFn(key)) expect(query?.state.data).toBe('test') + customQueryClient.clear() }) it('should refetch when changed enabled to true in error state', async () => { diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index f1ce24c4256..4cc96784017 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -4463,24 +4463,28 @@ describe('useQuery', () => { let hashes = 0 - function queryKeyHashFn(x: any) { + function hashFn(x: any) { hashes++ return JSON.stringify(x) } + const customQueryClient = new QueryClient({ + queryCache: new QueryCache({ hashFn }), + }) + function Page() { useQuery(() => ({ queryKey: key, queryFn: () => sleep(10).then(() => 'test'), - queryKeyHashFn, })) return null } - renderWithClient(queryClient, () => ) + renderWithClient(customQueryClient, () => ) expect(hashes).toBe(1) + customQueryClient.clear() }) it('should refetch when changed enabled to true in error state', async () => { diff --git a/packages/vue-query/src/__tests__/queryClient.test.ts b/packages/vue-query/src/__tests__/queryClient.test.ts index c126ebabfb8..35c22501a77 100644 --- a/packages/vue-query/src/__tests__/queryClient.test.ts +++ b/packages/vue-query/src/__tests__/queryClient.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ref, unref } from 'vue-demi' import { QueryClient as QueryClientOrigin } from '@tanstack/query-core' +import { ref } from 'vue-demi' +import { + QueryCache, + QueryClient as QueryClientOrigin, +} from '@tanstack/query-core' import { QueryClient } from '../queryClient' import { infiniteQueryOptions } from '../infiniteQueryOptions' import { queryOptions } from '../queryOptions' @@ -44,6 +49,27 @@ describe('QueryCache', () => { vi.useRealTimers() }) + it('should use the query cache key configuration', () => { + const queryClient = new QueryClient({ + queryCache: new QueryCache({ hashFn: () => 'custom-hash' }), + }) + + queryClient.setQueryData(['foo'], 'data') + expect(queryClient.getQueryData(['foo'])).toBe('data') + }) + + it('should use the query cache value serializer', () => { + const queryClient = new QueryClient({ + queryCache: new QueryCache({ + valueSerializer: (value) => + value instanceof Date ? value.toISOString() : value, + }), + }) + + queryClient.setQueryData(['dates', new Date(0)], 'data') + expect(queryClient.getQueryData(['dates', new Date(0)])).toBe('data') + }) + describe('isFetching', () => { it('should properly unwrap 1 parameter', () => { const queryClient = new QueryClient() From 591b6b744c7cfb02792e94fa45cc9265fe73b232 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 21 Aug 2026 09:56:59 +0200 Subject: [PATCH 02/10] deprecate queryKeyHashFn --- .../src/__tests__/queryClient.test-d.tsx | 3 +- .../src/__tests__/queryClient.test.tsx | 37 +++++++++++++++++++ packages/query-core/src/queryClient.ts | 22 +++++------ packages/query-core/src/types.ts | 4 ++ packages/query-core/src/utils.ts | 21 +++++++++-- 5 files changed, 70 insertions(+), 17 deletions(-) diff --git a/packages/query-core/src/__tests__/queryClient.test-d.tsx b/packages/query-core/src/__tests__/queryClient.test-d.tsx index 79837d106dc..bdcb30b5840 100644 --- a/packages/query-core/src/__tests__/queryClient.test-d.tsx +++ b/packages/query-core/src/__tests__/queryClient.test-d.tsx @@ -53,13 +53,12 @@ describe('cache key config', () => { expectTypeOf(queryClient).toEqualTypeOf() }) - it('should not allow query-level hash functions', () => { + it('should allow query-level hash functions', () => { const queryClient = new QueryClient() queryClient.fetchQuery({ queryKey: ['key'], queryFn: () => 'data', - // @ts-expect-error query key hashing is configured on QueryCache queryKeyHashFn: () => 'hash', }) diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index ed32c17c609..eb73b313718 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -248,6 +248,43 @@ describe('queryClient', () => { expect(hashFn).toHaveBeenCalledWith(['maps', [['a', 1]]]) }) + it('should use the query option hash function with serialized values', () => { + const valueSerializer = (value: unknown) => + value instanceof Map ? [...value.entries()] : value + const queryKeyHashFn = vi.fn((key: unknown) => JSON.stringify(key)) + const customQueryCache = new QueryCache({ valueSerializer }) + const testClient = new QueryClient({ queryCache: customQueryCache }) + const key = ['maps', new Map([['a', 1]])] + + const query = customQueryCache.build(testClient, { + queryKey: key, + queryKeyHashFn, + }) + + expect(query.queryHash).toBe(JSON.stringify(['maps', [['a', 1]]])) + expect(queryKeyHashFn).toHaveBeenCalledWith(['maps', [['a', 1]]]) + expect(customQueryCache.find({ queryKey: key })).toBe(query) + }) + + it('should prefer the cache hash function over the query option hash function', () => { + const cacheHashFn = vi.fn(() => 'cache-hash') + const queryKeyHashFn = vi.fn(() => 'query-hash') + const customQueryCache = new QueryCache({ hashFn: cacheHashFn }) + const testClient = new QueryClient({ queryCache: customQueryCache }) + const key = ['key'] + + const query = customQueryCache.build(testClient, { + queryKey: key, + queryKeyHashFn, + }) + + expect(query.queryHash).toBe('cache-hash') + expect(cacheHashFn).toHaveBeenCalledWith(key) + expect(queryKeyHashFn).not.toHaveBeenCalled() + expect(customQueryCache.find({ queryKey: key })).toBe(query) + expect(queryKeyHashFn).not.toHaveBeenCalled() + }) + it('should use the query cache value serializer for hashing and matching', () => { const valueSerializer = (value: unknown) => value instanceof Map ? JSON.stringify([...value.entries()]) : value diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 1f0de4975af..28e3dc36b88 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -76,7 +76,6 @@ function mergeCacheKeyDefaults( function serializeAndHashCacheKey( cacheKey: TCacheKey, config: Readonly>, - queryHash?: string, ): { cacheKey: CacheKey; queryHash: string } { const serializedCacheKey = serializeCacheKey( cacheKey, @@ -86,9 +85,7 @@ function serializeAndHashCacheKey( return { cacheKey: serializedCacheKey, queryHash: - queryHash ?? - config.hashFn?.(serializedCacheKey) ?? - hashKey(serializedCacheKey), + config.hashFn?.(serializedCacheKey) ?? hashKey(serializedCacheKey), } } @@ -689,12 +686,10 @@ export class QueryClient { > } - const { cacheKey: serializedCacheKey, queryHash } = - serializeAndHashCacheKey( - options.queryKey, - this.#queryCache.config, - options.queryHash, - ) + const serializedCacheKey = serializeCacheKey( + options.queryKey, + this.#queryCache.config.valueSerializer, + ) as TQueryKey const defaultedOptions = { ...this.#defaultOptions.queries, @@ -703,11 +698,14 @@ export class QueryClient { serializedCacheKey, ), ...options, - queryKey: serializedCacheKey as TQueryKey, + queryKey: serializedCacheKey, _defaulted: true, } - defaultedOptions.queryHash = queryHash + defaultedOptions.queryHash ??= + this.#queryCache.config.hashFn?.(serializedCacheKey) ?? + defaultedOptions.queryKeyHashFn?.(serializedCacheKey) ?? + hashKey(serializedCacheKey) // dependent default values if (defaultedOptions.refetchOnReconnect === undefined) { diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 77ab39bb782..1acf3e43af3 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -276,6 +276,10 @@ export interface QueryOptions< queryFn?: QueryFunction | SkipToken persister?: QueryPersister, TPageParam> queryHash?: string + /** + * @deprecated Configure `hashFn` on `QueryCache` instead. + */ + queryKeyHashFn?: QueryKeyHashFunction queryKey?: TQueryKey initialData?: TData | InitialDataFunction initialDataUpdatedAt?: number | (() => number | undefined) diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 550f5b72856..95ba477fac0 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -170,7 +170,14 @@ function matchQueryWithMatcher( ): boolean { const { type = 'all', fetchStatus, predicate, queryKey, stale } = filters - if (queryKey && !cacheKeyMatcher?.(query.queryKey, query.queryHash)) { + if ( + queryKey && + !cacheKeyMatcher?.( + query.queryKey, + query.queryHash, + query.options.queryKeyHashFn, + ) + ) { return false } @@ -249,6 +256,7 @@ function matchMutationWithMatcher( type CacheKeyMatcher = ( cacheKey: CacheKey, cacheHash: string | undefined, + hashFn?: (cacheKey: unknown) => string, ) => boolean /** @@ -313,9 +321,16 @@ function createCacheKeyMatcher( ) as TCacheKey if (exact) { - const hash = config.hashFn?.(serializedKey) ?? hashKey(serializedKey) + if (config.hashFn) { + const hash = config.hashFn(serializedKey) - return (_key, cacheHash) => cacheHash === hash + return (_key, cacheHash) => cacheHash === hash + } + + const defaultHash = hashKey(serializedKey) + + return (_key, cacheHash, hashFn) => + cacheHash === (hashFn?.(serializedKey) ?? defaultHash) } return (key) => partialMatchKey(key, serializedKey) From f7e8581fd92aa5d12a384ed0f6fdcb95c1e5f9dd Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 21 Aug 2026 11:47:53 +0200 Subject: [PATCH 03/10] store cache-key --- .changeset/cache-key-serializers.md | 8 ++ docs/framework/react/guides/query-keys.md | 16 +-- docs/reference/MutationCache.md | 2 +- docs/reference/QueryCache.md | 2 +- .../__tests__/infiniteQueryObserver.test.tsx | 1 + .../query-core/src/__tests__/query.test.tsx | 19 +++ .../src/__tests__/queryClient.test-d.tsx | 7 +- .../src/__tests__/queryClient.test.tsx | 117 +++++++++++++----- packages/query-core/src/index.ts | 1 - packages/query-core/src/mutation.ts | 27 ++-- packages/query-core/src/mutationObserver.ts | 13 +- packages/query-core/src/query.ts | 10 +- packages/query-core/src/queryCache.ts | 4 +- packages/query-core/src/queryClient.ts | 19 +-- packages/query-core/src/types.ts | 26 ++-- packages/query-core/src/utils.ts | 48 +++++-- .../src/createPersister.ts | 45 ++++--- 17 files changed, 254 insertions(+), 111 deletions(-) create mode 100644 .changeset/cache-key-serializers.md diff --git a/.changeset/cache-key-serializers.md b/.changeset/cache-key-serializers.md new file mode 100644 index 00000000000..4cc8ad1e816 --- /dev/null +++ b/.changeset/cache-key-serializers.md @@ -0,0 +1,8 @@ +--- +'@tanstack/query-core': minor +'@tanstack/query-persist-client-core': minor +--- + +Add cache-level value serialization and hashing for query and mutation keys. + +Configure `valueSerializer` and `hashFn` on `QueryCache` or `MutationCache` to use custom key values consistently in cache identity, filters, defaults, hydration, and persistence. The existing per-query `queryKeyHashFn` option is now deprecated. diff --git a/docs/framework/react/guides/query-keys.md b/docs/framework/react/guides/query-keys.md index 574363213ee..2500aab9534 100644 --- a/docs/framework/react/guides/query-keys.md +++ b/docs/framework/react/guides/query-keys.md @@ -94,16 +94,18 @@ const queryCache = new QueryCache({ const queryClient = new QueryClient({ queryCache }) ``` -When a serializer is configured, the serialized key becomes the canonical -`queryKey`. Any key exposed by the query APIs, including the key in a query -function context, has already been serialized. Configure `MutationCache` +When a serializer is configured, the original `queryKey` remains available to +query APIs and query function contexts. The serialized key is stored separately +and is used internally for hashing and matching. Configure `MutationCache` separately if mutation keys need custom serialization. If `hashFn` is provided on `QueryCache`, it receives this serialized key. The -same key is stored on cache entries and is used for matching, dehydration, and -persistence. The cache key configuration must not change while the cache -contains entries. A cache that restores dehydrated or persisted entries must -use a compatible key configuration. +serialized key is used for matching, dehydration, and persistence. A query that +is created only from restored data initially exposes this stored key. When the +query runs with normal query options, its query function receives the original +key from those options. The cache key configuration must not change while the +cache contains entries. A cache that restores dehydrated or persisted entries +must use a compatible key configuration. ## If your query function depends on a variable, include it in your query key diff --git a/docs/reference/MutationCache.md b/docs/reference/MutationCache.md index 83fb8773412..455f2ce79f1 100644 --- a/docs/reference/MutationCache.md +++ b/docs/reference/MutationCache.md @@ -35,7 +35,7 @@ Its available methods are: - Hashes serialized mutation keys into cache identity strings. - `valueSerializer?: (value: unknown) => unknown` - Optional - - Serializes values in mutation keys before hashing and matching. It must be idempotent. The serialized key becomes the canonical mutation key exposed by mutation APIs. + - Serializes values in mutation keys before hashing and matching. It must be idempotent. Mutation APIs continue to expose the original mutation key. The serialized key is used internally and is stored during dehydration and persistence. - The key configuration must not change while the cache contains entries. - `onError?: (error: unknown, variables: unknown, onMutateResult: unknown, mutation: Mutation, mutationFnContext: MutationFunctionContext) => Promise | unknown` - Optional diff --git a/docs/reference/QueryCache.md b/docs/reference/QueryCache.md index 75e27a1cebb..3c80ee636f2 100644 --- a/docs/reference/QueryCache.md +++ b/docs/reference/QueryCache.md @@ -42,7 +42,7 @@ Its available methods are: - Hashes serialized query keys into cache identity strings. - `valueSerializer?: (value: unknown) => unknown` - Optional - - Serializes values in query keys before hashing and matching. It must be idempotent. The serialized key becomes the canonical query key exposed by query APIs. + - Serializes values in query keys before hashing and matching. It must be idempotent. Query APIs continue to expose the original query key. The serialized key is used internally and is stored during dehydration and persistence. - The key configuration must not change while the cache contains entries. - `onError?: (error: unknown, query: Query) => void` - Optional diff --git a/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx b/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx index 8d94a69fb65..3bfacc07225 100644 --- a/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx +++ b/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx @@ -230,6 +230,7 @@ describe('InfiniteQueryObserver', () => { throwOnError: true, refetchOnReconnect: false, queryHash: key.join(''), + _cacheKey: key, _defaulted: true, behavior: undefined, } diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index dece8ebf157..73f10949f04 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -1218,6 +1218,25 @@ describe('query', () => { expect(query.options.queryFn).toBe(queryFn) }) + it('should serialize the cache key when constructed directly', () => { + const date = new Date(0) + const client = new QueryClient({ + queryCache: new QueryCache({ + valueSerializer: (value) => + value instanceof Date ? value.toISOString() : value, + }), + }) + + const query = new Query({ + client, + queryKey: ['dates', date], + queryHash: 'dates', + }) + + expect(query.queryKey).toEqual(['dates', date]) + expect(query.cacheKey).toEqual(['dates', date.toISOString()]) + }) + it('should log error when queryKey is not an array', async () => { const consoleMock = vi.spyOn(console, 'error') const key: unknown = 'string-key' diff --git a/packages/query-core/src/__tests__/queryClient.test-d.tsx b/packages/query-core/src/__tests__/queryClient.test-d.tsx index bdcb30b5840..4df1a4b2d63 100644 --- a/packages/query-core/src/__tests__/queryClient.test-d.tsx +++ b/packages/query-core/src/__tests__/queryClient.test-d.tsx @@ -10,6 +10,7 @@ import type { Query, QueryState } from '../query' import type { DataTag, DefaultError, + DefaultedMutationOptions, DefaultedQueryObserverOptions, EnsureQueryDataOptions, FetchInfiniteQueryOptions, @@ -529,7 +530,7 @@ describe('fully typed usage', () => { const mutationOptions2 = queryClient.defaultMutationOptions(mutationOptions) expectTypeOf(mutationOptions2).toEqualTypeOf< - MutationOptions + DefaultedMutationOptions> >() queryClient.setMutationDefaults(mutationKey, { @@ -684,7 +685,9 @@ describe('fully typed usage', () => { const mutationOptions2 = queryClient.defaultMutationOptions(mutationOptions) expectTypeOf(mutationOptions2).toEqualTypeOf< - MutationOptions + DefaultedMutationOptions< + MutationOptions + > >() queryClient.setMutationDefaults(mutationKey, { diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index eb73b313718..cfd62fd22e1 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -19,6 +19,7 @@ import type { InfiniteData, Query, QueryFunction, + QueryFunctionContext, QueryObserverOptions, } from '..' @@ -109,13 +110,12 @@ describe('queryClient', () => { mutationKey: ['date', date], }) - expect(testClient.getQueryCache().getAll()[0]?.queryKey).toEqual([ - 'date', - date.toISOString(), - ]) - expect( - testClient.getMutationCache().getAll()[0]?.options.mutationKey, - ).toEqual(['date', date.getTime()]) + const query = testClient.getQueryCache().getAll()[0] + const mutation = testClient.getMutationCache().getAll()[0] + expect(query?.queryKey).toEqual(['date', date]) + expect(query?.cacheKey).toEqual(['date', date.toISOString()]) + expect(mutation?.options.mutationKey).toEqual(['date', date]) + expect(mutation?.cacheKey).toEqual(['date', date.getTime()]) }) it('should not traverse a cache key when no value serializer is configured', () => { @@ -132,24 +132,28 @@ describe('queryClient', () => { expect(testClient.getQueryCache().getAll()[0]?.queryKey).toBe(key) }) - it('should expose the serialized query key everywhere', async () => { + it('should keep the original query key in public APIs', async () => { const valueSerializer = (value: unknown) => value instanceof Date ? value.toISOString() : value const testClient = new QueryClient({ queryCache: new QueryCache({ valueSerializer }), }) - const key = ['dates', new Date(0)] + const key = ['dates', new Date(0)] as const const serializedKey = ['dates', new Date(0).toISOString()] - const queryFn = vi.fn((_context: any) => 'data') + const queryFn = vi.fn((context: QueryFunctionContext) => + context.queryKey[1].getTime(), + ) - expect( - testClient.defaultQueryOptions({ queryKey: key }).queryKey, - ).toEqual(serializedKey) + const defaultedOptions = testClient.defaultQueryOptions({ queryKey: key }) + expect(defaultedOptions.queryKey).toBe(key) + expect(defaultedOptions._cacheKey).toEqual(serializedKey) await testClient.query({ queryKey: key, queryFn }) - expect(queryFn.mock.calls[0]?.[0].queryKey).toEqual(serializedKey) - expect(testClient.getQueryCache().getAll()[0]?.queryKey).toEqual( + expect(queryFn.mock.calls[0]?.[0].queryKey).toBe(key) + expect(queryFn).toHaveReturnedWith(0) + expect(testClient.getQueryCache().getAll()[0]?.queryKey).toBe(key) + expect(testClient.getQueryCache().getAll()[0]?.cacheKey).toEqual( serializedKey, ) }) @@ -187,6 +191,15 @@ describe('queryClient', () => { }, ]) expect(result.queryKey).toEqual([ + 'todos', + { + page: 1, + filters: ['active', 2], + nullable: null, + missing: undefined, + }, + ]) + expect(result._cacheKey).toEqual([ 'todos', { page: '1', @@ -216,6 +229,45 @@ describe('queryClient', () => { ]) }) + it('should serialize values inside containers returned by the serializer', () => { + const testClient = new QueryClient({ + queryCache: new QueryCache({ + valueSerializer: (value) => + value instanceof Map + ? [...value.entries()] + : typeof value === 'bigint' + ? String(value) + : value, + }), + }) + + const options = testClient.defaultQueryOptions({ + queryKey: ['counts', new Map([['total', 1n]])], + }) + + expect(options._cacheKey).toEqual(['counts', [['total', '1']]]) + expect(options.queryHash).toBe('["counts",[["total","1"]]]') + }) + + it('should preserve unchanged key branches while serializing', () => { + const unchanged = { status: 'active' } + const key = ['todos', unchanged, new Date(0)] + const testClient = new QueryClient({ + queryCache: new QueryCache({ + valueSerializer: (value) => + value instanceof Date ? value.toISOString() : value, + }), + }) + + const cacheKey = testClient.defaultQueryOptions({ + queryKey: key, + })._cacheKey + + expect(cacheKey).not.toBe(key) + expect(cacheKey[1]).toBe(unchanged) + expect(cacheKey[2]).toBe(new Date(0).toISOString()) + }) + it('should use the cache hash function for query identity', () => { const hashFn = vi.fn((key: unknown) => JSON.stringify(key, (_, value) => @@ -301,7 +353,7 @@ describe('queryClient', () => { expect(testClient.getQueryCache().getAll()).toHaveLength(2) expect( testClient.getQueriesData({ queryKey: ['maps', new Map([['a', 1]])] }), - ).toEqual([[['maps', '[["a",1]]'], 'a']]) + ).toEqual([[keyA, 'a']]) }) it('should use the mutation cache value serializer for matching', () => { @@ -318,11 +370,14 @@ describe('queryClient', () => { mutationFn: () => Promise.resolve(), }) - expect(valueSerializer).toHaveBeenCalledTimes(2) + expect(valueSerializer).toHaveBeenCalledTimes(4) expect(hashFn).toHaveBeenCalledTimes(1) - expect( - testClient.getMutationCache().getAll()[0]?.options.mutationKey, - ).toEqual(['maps', [['a', 1]]]) + const mutation = testClient.getMutationCache().getAll()[0] + expect(mutation?.options.mutationKey).toEqual([ + 'maps', + new Map([['a', 1]]), + ]) + expect(mutation?.cacheKey).toEqual(['maps', [['a', 1]]]) expect( testClient.getMutationCache().findAll({ @@ -369,14 +424,14 @@ describe('queryClient', () => { queryKey: ['maps', new Map([['a', 1]])], exact: true, }), - ).toEqual([[['maps', [['a', 1]]], 'a']]) + ).toEqual([[keyA, 'a']]) expect(testClient.getQueriesData({ queryKey: ['maps'] })).toEqual([ - [['maps', [['a', 1]]], 'a'], - [['maps', [['a', 2]]], 'b'], + [keyA, 'a'], + [keyB, 'b'], ]) }) - it('should support re-serializing canonical keys in setQueriesData', () => { + it('should keep original keys in setQueriesData', () => { const valueSerializer = vi.fn((value: unknown) => typeof value === 'string' && !value.endsWith('!') ? `${value}!` : value, ) @@ -390,7 +445,8 @@ describe('queryClient', () => { testClient.setQueriesData({ queryKey: ['key'] }, () => 'b') expect(valueSerializer).toHaveBeenCalled() - expect(testClient.getQueryCache().getAll()[0]?.queryKey).toEqual(['key!']) + expect(testClient.getQueryCache().getAll()[0]?.queryKey).toEqual(['key']) + expect(testClient.getQueryCache().getAll()[0]?.cacheKey).toEqual(['key!']) expect(testClient.getQueryData(['key'])).toBe('b') }) @@ -410,7 +466,8 @@ describe('queryClient', () => { }), ) - expect(query.queryKey).toEqual(['maps', [['a', 1]]]) + expect(query.queryKey).toEqual(['maps', new Map([['a', 1]])]) + expect(query.cacheKey).toEqual(['maps', [['a', 1]]]) expect(hashFn).toHaveBeenCalledTimes(1) }) @@ -435,16 +492,16 @@ describe('queryClient', () => { queryKey: ['maps', new Map([['a', 1]])], exact: true, }), - ).toEqual([[['maps', [['a', 1]]], 'data']]) - expect(valueSerializer).toHaveBeenCalledTimes(2) + ).toEqual([[key, 'data']]) + expect(valueSerializer).toHaveBeenCalledTimes(4) expect(hashFn).toHaveBeenCalledTimes(1) valueSerializer.mockClear() hashFn.mockClear() expect(testClient.getQueriesData({ queryKey: ['maps'] })).toEqual([ - [['maps', [['a', 1]]], 'data'], - [['maps', [['a', 2]]], 'other data'], + [key, 'data'], + [otherKey, 'other data'], ]) expect(valueSerializer).toHaveBeenCalledTimes(1) expect(hashFn).not.toHaveBeenCalled() diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 09a920e4d42..a5e39e57aa1 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -38,7 +38,6 @@ export { noop, partialMatchKey, replaceEqualDeep, - serializeCacheKey, shouldThrowError, skipToken, } from './utils' diff --git a/packages/query-core/src/mutation.ts b/packages/query-core/src/mutation.ts index 63e1d820169..3f4ae2f12df 100644 --- a/packages/query-core/src/mutation.ts +++ b/packages/query-core/src/mutation.ts @@ -1,10 +1,11 @@ import { notifyManager } from './notifyManager' import { Removable } from './removable' import { createRetryer } from './retryer' -import { hashKey } from './utils' +import { hashKey, serializeCacheKey } from './utils' import type { CacheKeyConfig, DefaultError, + DefaultedMutationOptions, MutationFunctionContext, MutationKey, MutationMeta, @@ -93,6 +94,7 @@ export class Mutation< > extends Removable { state: MutationState options!: MutationOptions + cacheKey?: MutationKey mutationHash?: string readonly mutationId: number @@ -111,16 +113,27 @@ export class Mutation< this.#client = config.client this.mutationId = config.mutationId this.#mutationCache = config.mutationCache - this.mutationHash = - config.mutationHash ?? - (config.options.mutationKey - ? (this.#mutationCache.config.hashFn?.(config.options.mutationKey) ?? - hashKey(config.options.mutationKey)) - : undefined) this.#observers = [] this.state = config.state || getDefaultState() this.setOptions(config.options) + const cacheKey = ( + config.options as DefaultedMutationOptions + )._cacheKey + this.cacheKey = + cacheKey ?? + (config.options.mutationKey + ? serializeCacheKey( + config.options.mutationKey, + this.#mutationCache.config.valueSerializer, + ) + : undefined) + this.mutationHash = + config.mutationHash ?? + (this.cacheKey + ? (this.#mutationCache.config.hashFn?.(this.cacheKey) ?? + hashKey(this.cacheKey)) + : undefined) this.scheduleGc() } diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index 1c6b0a046fc..a0a879678c8 100644 --- a/packages/query-core/src/mutationObserver.ts +++ b/packages/query-core/src/mutationObserver.ts @@ -5,6 +5,7 @@ import { hashKey, shallowEqualObjects } from './utils' import type { QueryClient } from './queryClient' import type { DefaultError, + DefaultedMutationOptions, MutateOptions, MutationFunctionContext, MutationObserverOptions, @@ -71,9 +72,10 @@ export class MutationObserver< >, ) { const prevOptions = this.options as - | MutationObserverOptions + | DefaultedMutationOptions | undefined - this.options = this.#client.defaultMutationOptions(options) + const defaultedOptions = this.#client.defaultMutationOptions(options) + this.options = defaultedOptions if (!shallowEqualObjects(this.options, prevOptions)) { this.#client.getMutationCache().notify({ type: 'observerOptionsUpdated', @@ -86,12 +88,13 @@ export class MutationObserver< if ( prevOptions?.mutationKey && - this.options.mutationKey && - hashFn(prevOptions.mutationKey) !== hashFn(this.options.mutationKey) + defaultedOptions.mutationKey && + hashFn(prevOptions._cacheKey ?? prevOptions.mutationKey) !== + hashFn(defaultedOptions._cacheKey ?? defaultedOptions.mutationKey) ) { this.reset() } else if (this.#currentMutation?.state.status === 'pending') { - this.#currentMutation.setOptions(this.options) + this.#currentMutation.setOptions(defaultedOptions) } } diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index c6c765f016b..22e36bb2be0 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -4,6 +4,7 @@ import { replaceData, resolveQueryBoolean, resolveStaleTime, + serializeCacheKey, skipToken, timeUntilStale, } from './utils' @@ -40,6 +41,7 @@ interface QueryConfig< TQueryKey extends QueryKey = QueryKey, > { client: QueryClient + cacheKey?: QueryKey queryKey: TQueryKey queryHash: string options?: QueryOptions @@ -159,6 +161,7 @@ export class Query< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > extends Removable { + readonly cacheKey: QueryKey queryKey: TQueryKey queryHash: string options!: QueryOptions @@ -179,10 +182,13 @@ export class Query< this.#abortSignalConsumed = false this.#defaultOptions = config.defaultOptions - this.setOptions(config.options) - this.observers = [] this.#client = config.client this.#cache = this.#client.getQueryCache() + this.setOptions(config.options) + this.observers = [] + this.cacheKey = + config.cacheKey ?? + serializeCacheKey(config.queryKey, this.#cache.config.valueSerializer) this.queryKey = config.queryKey this.queryHash = config.queryHash this.#initialState = getDefaultState(this.options) diff --git a/packages/query-core/src/queryCache.ts b/packages/query-core/src/queryCache.ts index 2bc37177565..47d4754ef1c 100644 --- a/packages/query-core/src/queryCache.ts +++ b/packages/query-core/src/queryCache.ts @@ -113,17 +113,19 @@ export class QueryCache extends Subscribable { ): Query { const defaultedOptions = client.defaultQueryOptions(options) const queryKey = defaultedOptions.queryKey + const cacheKey = defaultedOptions._cacheKey const queryHash = defaultedOptions.queryHash let query = this.get(queryHash) if (!query) { query = new Query({ client, + cacheKey, queryKey, queryHash, options: defaultedOptions, state, - defaultOptions: client.getQueryDefaults(queryKey), + defaultOptions: client.getQueryDefaults(queryKey, cacheKey), }) this.add(query) } diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 28e3dc36b88..e96e5644180 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -19,6 +19,7 @@ import type { CancelOptions, DefaultError, DefaultOptions, + DefaultedMutationOptions, DefaultedQueryObserverOptions, EnsureInfiniteQueryDataOptions, EnsureQueryDataOptions, @@ -605,11 +606,11 @@ export class QueryClient { getQueryDefaults( queryKey: QueryKey, + _cacheKey?: QueryKey, ): OmitKeyof, 'queryKey'> { - const cacheKey = serializeCacheKey( - queryKey, - this.#queryCache.config.valueSerializer, - ) + const cacheKey = + _cacheKey ?? + serializeCacheKey(queryKey, this.#queryCache.config.valueSerializer) return mergeCacheKeyDefaults(this.#queryDefaults.values(), cacheKey) } @@ -698,7 +699,7 @@ export class QueryClient { serializedCacheKey, ), ...options, - queryKey: serializedCacheKey, + _cacheKey: serializedCacheKey, _defaulted: true, } @@ -735,9 +736,9 @@ export class QueryClient { defaultMutationOptions>( options?: T, - ): T { + ): DefaultedMutationOptions { if (options?._defaulted) { - return options + return options as DefaultedMutationOptions } const serializedCacheKey = options?.mutationKey ? serializeCacheKey( @@ -755,10 +756,10 @@ export class QueryClient { )), ...options, ...(serializedCacheKey && { - mutationKey: serializedCacheKey, + _cacheKey: serializedCacheKey, }), _defaulted: true, - } as T + } as DefaultedMutationOptions } clear(): void { diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 1acf3e43af3..1c88b68a1a2 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -209,8 +209,8 @@ export interface CacheKeyConfig { readonly hashFn?: CacheKeyHashFunction /** * Serializes cache key values. The serializer must be idempotent because a - * key can be serialized more than once. The serialized key is the canonical - * key used for hashing, matching, cache entries, and operation contexts. + * key can be serialized more than once. The serialized key is used + * internally for hashing, matching, dehydration, and persistence. */ readonly valueSerializer?: CacheKeyValueSerializer } @@ -478,19 +478,7 @@ export type DefaultedQueryObserverOptions< QueryObserverOptions, 'throwOnError' | 'refetchOnReconnect' | 'queryHash' > & { - _defaulted: true -} - -export type DefaultedQueryOptions< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, - TPageParam = never, -> = WithRequired< - QueryOptions, - 'queryKey' | 'queryHash' -> & { + _cacheKey: QueryKey _defaulted: true } @@ -528,6 +516,7 @@ export type DefaultedInfiniteQueryObserverOptions< >, 'throwOnError' | 'refetchOnReconnect' | 'queryHash' > & { + _cacheKey: QueryKey _defaulted: true } @@ -1187,6 +1176,13 @@ export interface MutationOptions< scope?: MutationScope } +export type DefaultedMutationOptions< + TOptions extends MutationOptions, +> = TOptions & { + _cacheKey?: MutationKey + _defaulted: true +} + export interface MutationObserverOptions< TData = unknown, TError = DefaultError, diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 95ba477fac0..a5da52678a7 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -173,7 +173,7 @@ function matchQueryWithMatcher( if ( queryKey && !cacheKeyMatcher?.( - query.queryKey, + query.cacheKey, query.queryHash, query.options.queryKeyHashFn, ) @@ -236,7 +236,8 @@ function matchMutationWithMatcher( if (mutationKey) { if ( !mutation.options.mutationKey || - !cacheKeyMatcher?.(mutation.options.mutationKey, mutation.mutationHash) + !mutation.cacheKey || + !cacheKeyMatcher?.(mutation.cacheKey, mutation.mutationHash) ) { return false } @@ -281,18 +282,51 @@ function serializeCacheKeyValue( serializer: CacheKeyValueSerializer | undefined, ): unknown { if (Array.isArray(value)) { - return value.map((item) => serializeCacheKeyValue(item, serializer)) + let result: Array | undefined + + for (let index = 0; index < value.length; index++) { + if (!(index in value)) { + continue + } + + const item = value[index] + const serializedItem = serializeCacheKeyValue(item, serializer) + if (serializedItem !== item) { + result ??= value.slice() + result[index] = serializedItem + } + } + + return result ?? value } if (isPlainObject(value)) { - const result: Record = {} + let result: Record | undefined + for (const key of Object.keys(value)) { - result[key] = serializeCacheKeyValue(value[key], serializer) + const item = value[key] + const serializedItem = serializeCacheKeyValue(item, serializer) + if (serializedItem !== item) { + result ??= { ...value } + result[key] = serializedItem + } } - return result + + return result ?? value + } + + if (!serializer) { + return value + } + + const serializedValue = serializer(value) + if (serializedValue === value) { + return value } - return serializer ? serializer(value) : value + return Array.isArray(serializedValue) || isPlainObject(serializedValue) + ? serializeCacheKeyValue(serializedValue, serializer) + : serializedValue } export function serializeCacheKey( diff --git a/packages/query-persist-client-core/src/createPersister.ts b/packages/query-persist-client-core/src/createPersister.ts index d8bc57659db..890273dab4e 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -1,9 +1,7 @@ import { - hashKey, matchQuery, notifyManager, partialMatchKey, - serializeCacheKey, } from '@tanstack/query-core' import type { Query, @@ -123,6 +121,21 @@ export function experimental_createQueryPersister({ return true } + function getCacheKeyFilter( + queryClient: QueryClient, + queryKey: QueryKey | undefined, + ) { + if (!queryKey) { + return {} + } + + const options = queryClient.defaultQueryOptions({ queryKey }) + return { + cacheKey: options._cacheKey, + cacheHash: options.queryHash, + } + } + async function retrieveQuery( queryHash: string, afterRestoreMacroTask?: (persistedQuery: PersistedQuery) => void, @@ -193,7 +206,7 @@ export function experimental_createQueryPersister({ storageKey, await serialize({ state: query.state, - queryKey: query.queryKey, + queryKey: query.cacheKey, queryHash: query.queryHash, buster: buster, }), @@ -273,17 +286,10 @@ export function experimental_createQueryPersister({ async function restoreQueries( queryClient: QueryClient, - filters: Pick = {}, + queryFilters: Pick = {}, ): Promise { - const { exact, queryKey } = filters - const config = queryClient.getQueryCache().config - const cacheKey = queryKey - ? serializeCacheKey(queryKey, config.valueSerializer) - : undefined - const cacheHash = - cacheKey && exact - ? (config.hashFn?.(cacheKey) ?? hashKey(cacheKey)) - : undefined + const { exact, queryKey } = queryFilters + const { cacheKey, cacheHash } = getCacheKeyFilter(queryClient, queryKey) if (storage?.entries) { const storageKeyPrefix = `${prefix}-` @@ -330,17 +336,10 @@ export function experimental_createQueryPersister({ async function removeQueries( queryClient: QueryClient, - filters: Pick = {}, + queryFilters: Pick = {}, ): Promise { - const { exact, queryKey } = filters - const config = queryClient.getQueryCache().config - const cacheKey = queryKey - ? serializeCacheKey(queryKey, config.valueSerializer) - : undefined - const cacheHash = - cacheKey && exact - ? (config.hashFn?.(cacheKey) ?? hashKey(cacheKey)) - : undefined + const { exact, queryKey } = queryFilters + const { cacheKey, cacheHash } = getCacheKeyFilter(queryClient, queryKey) if (storage?.entries) { const entries = await storage.entries() From 60cd3d10835980f2f79277e427f5a1c72187cdb3 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 21 Aug 2026 14:06:25 +0200 Subject: [PATCH 04/10] switch to on-demand weakmap cache --- docs/framework/react/guides/query-keys.md | 15 +- docs/reference/MutationCache.md | 4 +- docs/reference/QueryCache.md | 4 +- .../src/__tests__/hydration.test.tsx | 16 +- .../__tests__/infiniteQueryObserver.test.tsx | 1 - .../src/__tests__/mutationCache.test.tsx | 6 +- .../query-core/src/__tests__/query.test.tsx | 19 --- .../src/__tests__/queryCache.test.tsx | 6 +- .../src/__tests__/queryClient.test.tsx | 30 +--- .../query-core/src/__tests__/utils.test.tsx | 63 ++++++++ packages/query-core/src/index.ts | 1 + packages/query-core/src/mutation.ts | 25 ++- packages/query-core/src/mutationCache.ts | 8 +- packages/query-core/src/mutationObserver.ts | 11 +- packages/query-core/src/query.ts | 6 - packages/query-core/src/queryCache.ts | 12 +- packages/query-core/src/queryClient.ts | 74 ++++----- packages/query-core/src/types.ts | 9 +- packages/query-core/src/utils.ts | 152 +++++++----------- .../src/__tests__/createPersister.test.ts | 74 ++++++++- .../src/createPersister.ts | 40 +++-- 21 files changed, 311 insertions(+), 265 deletions(-) diff --git a/docs/framework/react/guides/query-keys.md b/docs/framework/react/guides/query-keys.md index 2500aab9534..8b287ac0c3e 100644 --- a/docs/framework/react/guides/query-keys.md +++ b/docs/framework/react/guides/query-keys.md @@ -94,18 +94,9 @@ const queryCache = new QueryCache({ const queryClient = new QueryClient({ queryCache }) ``` -When a serializer is configured, the original `queryKey` remains available to -query APIs and query function contexts. The serialized key is stored separately -and is used internally for hashing and matching. Configure `MutationCache` -separately if mutation keys need custom serialization. - -If `hashFn` is provided on `QueryCache`, it receives this serialized key. The -serialized key is used for matching, dehydration, and persistence. A query that -is created only from restored data initially exposes this stored key. When the -query runs with normal query options, its query function receives the original -key from those options. The cache key configuration must not change while the -cache contains entries. A cache that restores dehydrated or persisted entries -must use a compatible key configuration. +Serialized results are memoized by serializer and key reference, then used internally for hashing and matching. Configure `MutationCache` separately if mutation keys need custom serialization. + +If `hashFn` is provided on `QueryCache`, it receives this serialized key. The serialized key is used for matching. Dehydration keeps the original key. Fine-grained persistence keeps the original key and normalizes it when applying partial filters. Custom persistence codecs are required to preserve runtime key types such as `Date` or `Map`. The cache key configuration must not change while the cache contains entries. A cache that restores dehydrated or persisted entries must use a compatible key configuration. ## If your query function depends on a variable, include it in your query key diff --git a/docs/reference/MutationCache.md b/docs/reference/MutationCache.md index 455f2ce79f1..dbc7ddf7cf2 100644 --- a/docs/reference/MutationCache.md +++ b/docs/reference/MutationCache.md @@ -35,8 +35,8 @@ Its available methods are: - Hashes serialized mutation keys into cache identity strings. - `valueSerializer?: (value: unknown) => unknown` - Optional - - Serializes values in mutation keys before hashing and matching. It must be idempotent. Mutation APIs continue to expose the original mutation key. The serialized key is used internally and is stored during dehydration and persistence. - - The key configuration must not change while the cache contains entries. + - Serializes values in mutation keys before hashing and matching. The serializer must be deterministic and idempotent, and mutation keys must not be changed after use. + - Mutation APIs continue to expose the original mutation key. The key configuration must not change while the cache contains entries. - `onError?: (error: unknown, variables: unknown, onMutateResult: unknown, mutation: Mutation, mutationFnContext: MutationFunctionContext) => Promise | unknown` - Optional - This function will be called if some mutation encounters an error. diff --git a/docs/reference/QueryCache.md b/docs/reference/QueryCache.md index 3c80ee636f2..5f45b567de3 100644 --- a/docs/reference/QueryCache.md +++ b/docs/reference/QueryCache.md @@ -42,8 +42,8 @@ Its available methods are: - Hashes serialized query keys into cache identity strings. - `valueSerializer?: (value: unknown) => unknown` - Optional - - Serializes values in query keys before hashing and matching. It must be idempotent. Query APIs continue to expose the original query key. The serialized key is used internally and is stored during dehydration and persistence. - - The key configuration must not change while the cache contains entries. + - Serializes values in query keys before hashing and matching. The serializer must be deterministic and idempotent, and query keys must not be changed after use. + - Query APIs continue to expose the original query key. The key configuration must not change while the cache contains entries. - `onError?: (error: unknown, query: Query) => void` - Optional - This function will be called if some query encounters an error. diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 8005224320b..3968547c37a 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -113,7 +113,7 @@ describe('dehydration and rehydration', () => { hydrationClient.clear() }) - it('should dehydrate the serialized cache key and serialize it again during hydration', () => { + it('should preserve original keys during dehydration and hydration', () => { const serializeValue = (value: unknown) => value instanceof Date ? value.toISOString() : value const serverClient = new QueryClient({ @@ -131,14 +131,8 @@ describe('dehydration and rehydration', () => { const dehydrated = dehydrate(serverClient, { shouldDehydrateMutation: () => true, }) - expect(dehydrated.queries[0]?.queryKey).toEqual([ - 'dates', - new Date(0).toISOString(), - ]) - expect(dehydrated.mutations[0]?.mutationKey).toEqual([ - 'dates', - new Date(0).toISOString(), - ]) + expect(dehydrated.queries[0]?.queryKey).toEqual(['dates', new Date(0)]) + expect(dehydrated.mutations[0]?.mutationKey).toEqual(['dates', new Date(0)]) const valueSerializer = vi.fn(serializeValue) const client = new QueryClient({ @@ -149,6 +143,10 @@ describe('dehydration and rehydration', () => { expect(valueSerializer).toHaveBeenCalled() expect(client.getQueryData(key)).toBe('data') + expect(client.getQueryCache().getAll()[0]?.queryKey).toEqual(key) + expect(client.getMutationCache().getAll()[0]?.options.mutationKey).toEqual( + key, + ) }) it('should not dehydrate queries if dehydrateQueries is set to false', async () => { diff --git a/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx b/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx index 3bfacc07225..8d94a69fb65 100644 --- a/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx +++ b/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx @@ -230,7 +230,6 @@ describe('InfiniteQueryObserver', () => { throwOnError: true, refetchOnReconnect: false, queryHash: key.join(''), - _cacheKey: key, _defaulted: true, behavior: undefined, } diff --git a/packages/query-core/src/__tests__/mutationCache.test.tsx b/packages/query-core/src/__tests__/mutationCache.test.tsx index 40c42803cc0..23000a8772c 100644 --- a/packages/query-core/src/__tests__/mutationCache.test.tsx +++ b/packages/query-core/src/__tests__/mutationCache.test.tsx @@ -349,7 +349,7 @@ describe('mutationCache', () => { expect(testCache.findAll({ mutationKey: ['unknown'] })).toEqual([]) }) - it('should use the cache serializer once when clients share a cache', () => { + it('should use the shared cache serializer when clients share a cache', () => { const valueSerializer = vi.fn((value: unknown) => value instanceof Date ? value.getTime() : value, ) @@ -379,7 +379,7 @@ describe('mutationCache', () => { numberMutation, ) expect(valueSerializer).toHaveBeenCalledTimes(2) - expect(hashFn).toHaveBeenCalledTimes(1) + expect(hashFn).toHaveBeenCalledTimes(2) valueSerializer.mockClear() hashFn.mockClear() @@ -388,7 +388,7 @@ describe('mutationCache', () => { testCache.findAll({ mutationKey: ['number', date], exact: true }), ).toEqual([numberMutation]) expect(valueSerializer).toHaveBeenCalledTimes(2) - expect(hashFn).toHaveBeenCalledTimes(1) + expect(hashFn).toHaveBeenCalledTimes(3) stringClient.clear() }) diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index 73f10949f04..dece8ebf157 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -1218,25 +1218,6 @@ describe('query', () => { expect(query.options.queryFn).toBe(queryFn) }) - it('should serialize the cache key when constructed directly', () => { - const date = new Date(0) - const client = new QueryClient({ - queryCache: new QueryCache({ - valueSerializer: (value) => - value instanceof Date ? value.toISOString() : value, - }), - }) - - const query = new Query({ - client, - queryKey: ['dates', date], - queryHash: 'dates', - }) - - expect(query.queryKey).toEqual(['dates', date]) - expect(query.cacheKey).toEqual(['dates', date.toISOString()]) - }) - it('should log error when queryKey is not an array', async () => { const consoleMock = vi.spyOn(console, 'error') const key: unknown = 'string-key' diff --git a/packages/query-core/src/__tests__/queryCache.test.tsx b/packages/query-core/src/__tests__/queryCache.test.tsx index fd84309b18d..743f9d3af46 100644 --- a/packages/query-core/src/__tests__/queryCache.test.tsx +++ b/packages/query-core/src/__tests__/queryCache.test.tsx @@ -313,7 +313,7 @@ describe('queryCache', () => { expect(queryCache.findAll().length).toBe(2) }) - it('should use the cache serializer once when clients share a cache', () => { + it('should use the shared cache serializer when clients share a cache', () => { const valueSerializer = vi.fn((value: unknown) => value instanceof Date ? value.getTime() : value, ) @@ -337,7 +337,7 @@ describe('queryCache', () => { expect(testCache.find({ queryKey: ['number', date] })).toBe(numberQuery) expect(valueSerializer).toHaveBeenCalledTimes(2) - expect(hashFn).toHaveBeenCalledTimes(1) + expect(hashFn).toHaveBeenCalledTimes(2) valueSerializer.mockClear() hashFn.mockClear() @@ -346,7 +346,7 @@ describe('queryCache', () => { testCache.findAll({ queryKey: ['number', date], exact: true }), ).toEqual([numberQuery]) expect(valueSerializer).toHaveBeenCalledTimes(2) - expect(hashFn).toHaveBeenCalledTimes(1) + expect(hashFn).toHaveBeenCalledTimes(3) stringClient.clear() }) diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index cfd62fd22e1..b0b09749641 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -12,6 +12,7 @@ import { hydrate, noop, onlineManager, + serializeCacheKey, skipToken, } from '..' import { mockOnlineManagerIsOnline } from './utils' @@ -113,9 +114,7 @@ describe('queryClient', () => { const query = testClient.getQueryCache().getAll()[0] const mutation = testClient.getMutationCache().getAll()[0] expect(query?.queryKey).toEqual(['date', date]) - expect(query?.cacheKey).toEqual(['date', date.toISOString()]) expect(mutation?.options.mutationKey).toEqual(['date', date]) - expect(mutation?.cacheKey).toEqual(['date', date.getTime()]) }) it('should not traverse a cache key when no value serializer is configured', () => { @@ -139,23 +138,18 @@ describe('queryClient', () => { queryCache: new QueryCache({ valueSerializer }), }) const key = ['dates', new Date(0)] as const - const serializedKey = ['dates', new Date(0).toISOString()] const queryFn = vi.fn((context: QueryFunctionContext) => context.queryKey[1].getTime(), ) const defaultedOptions = testClient.defaultQueryOptions({ queryKey: key }) expect(defaultedOptions.queryKey).toBe(key) - expect(defaultedOptions._cacheKey).toEqual(serializedKey) await testClient.query({ queryKey: key, queryFn }) expect(queryFn.mock.calls[0]?.[0].queryKey).toBe(key) expect(queryFn).toHaveReturnedWith(0) expect(testClient.getQueryCache().getAll()[0]?.queryKey).toBe(key) - expect(testClient.getQueryCache().getAll()[0]?.cacheKey).toEqual( - serializedKey, - ) }) it('should serialize nested arrays and objects recursively', () => { @@ -199,15 +193,6 @@ describe('queryClient', () => { missing: undefined, }, ]) - expect(result._cacheKey).toEqual([ - 'todos', - { - page: '1', - filters: ['active', '2'], - nullable: null, - missing: undefined, - }, - ]) expect(result.queryHash).toBe( JSON.stringify([ 'todos', @@ -245,7 +230,6 @@ describe('queryClient', () => { queryKey: ['counts', new Map([['total', 1n]])], }) - expect(options._cacheKey).toEqual(['counts', [['total', '1']]]) expect(options.queryHash).toBe('["counts",[["total","1"]]]') }) @@ -259,9 +243,10 @@ describe('queryClient', () => { }), }) - const cacheKey = testClient.defaultQueryOptions({ - queryKey: key, - })._cacheKey + const cacheKey = serializeCacheKey( + key, + testClient.getQueryCache().config.valueSerializer, + ) expect(cacheKey).not.toBe(key) expect(cacheKey[1]).toBe(unchanged) @@ -377,7 +362,6 @@ describe('queryClient', () => { 'maps', new Map([['a', 1]]), ]) - expect(mutation?.cacheKey).toEqual(['maps', [['a', 1]]]) expect( testClient.getMutationCache().findAll({ @@ -446,7 +430,6 @@ describe('queryClient', () => { expect(valueSerializer).toHaveBeenCalled() expect(testClient.getQueryCache().getAll()[0]?.queryKey).toEqual(['key']) - expect(testClient.getQueryCache().getAll()[0]?.cacheKey).toEqual(['key!']) expect(testClient.getQueryData(['key'])).toBe('b') }) @@ -467,7 +450,6 @@ describe('queryClient', () => { ) expect(query.queryKey).toEqual(['maps', new Map([['a', 1]])]) - expect(query.cacheKey).toEqual(['maps', [['a', 1]]]) expect(hashFn).toHaveBeenCalledTimes(1) }) @@ -494,7 +476,7 @@ describe('queryClient', () => { }), ).toEqual([[key, 'data']]) expect(valueSerializer).toHaveBeenCalledTimes(4) - expect(hashFn).toHaveBeenCalledTimes(1) + expect(hashFn).toHaveBeenCalledTimes(2) valueSerializer.mockClear() hashFn.mockClear() diff --git a/packages/query-core/src/__tests__/utils.test.tsx b/packages/query-core/src/__tests__/utils.test.tsx index b526dff0486..c960212aef2 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -15,6 +15,7 @@ import { matchQuery, partialMatchKey, replaceEqualDeep, + serializeCacheKey, shallowEqualObjects, shouldThrowError, skipToken, @@ -23,6 +24,68 @@ import { Mutation } from '../mutation' import type { QueryFunctionContext } from '..' describe('core/utils', () => { + describe('serializeCacheKey', () => { + it('memoizes by serializer and key reference', () => { + const serializer = vi.fn((value: unknown) => + value instanceof Date ? value.toISOString() : value, + ) + const date = new Date(0) + const key = ['dates', date] + + const first = serializeCacheKey(key, serializer) + const second = serializeCacheKey(key, serializer) + + expect(second).toBe(first) + expect(serializer).toHaveBeenCalledTimes(2) + + const otherSerializer = vi.fn(serializer.getMockImplementation()) + expect(serializeCacheKey(key, otherSerializer)).not.toBe(first) + expect(otherSerializer).toHaveBeenCalledTimes(2) + + const otherKey = ['dates', date] + expect(serializeCacheKey(otherKey, serializer)).not.toBe(first) + expect(serializer).toHaveBeenCalledTimes(4) + }) + + it('returns the original key without a serializer', () => { + const key = ['maps', new Map([['a', 1]])] + + expect(serializeCacheKey(key, undefined)).toBe(key) + }) + + it('caches recursive serializer results', () => { + const serializer = vi.fn((value: unknown) => + value instanceof Map + ? [...value.entries()] + : typeof value === 'bigint' + ? String(value) + : value, + ) + const key = ['counts', new Map([['total', 1n]])] + + const serialized = serializeCacheKey(key, serializer) + + expect(serialized).toEqual(['counts', [['total', '1']]]) + expect(serializeCacheKey(key, serializer)).toBe(serialized) + expect(serializer).toHaveBeenCalledTimes(4) + }) + + it('does not cache failed serialization', () => { + const serializer = vi.fn(() => { + throw new Error('serialize failed') + }) + const key = ['broken', new Date(0)] + + expect(() => serializeCacheKey(key, serializer)).toThrow( + 'serialize failed', + ) + expect(() => serializeCacheKey(key, serializer)).toThrow( + 'serialize failed', + ) + expect(serializer).toHaveBeenCalledTimes(2) + }) + }) + it('should return `false` for same-length objects with different keys', () => { // Both objects have the same number of keys, but the key sets differ. // `b` is missing on the second object and `c` is missing on the first, diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index a5e39e57aa1..09a920e4d42 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -38,6 +38,7 @@ export { noop, partialMatchKey, replaceEqualDeep, + serializeCacheKey, shouldThrowError, skipToken, } from './utils' diff --git a/packages/query-core/src/mutation.ts b/packages/query-core/src/mutation.ts index 3f4ae2f12df..7e09a3069bc 100644 --- a/packages/query-core/src/mutation.ts +++ b/packages/query-core/src/mutation.ts @@ -5,7 +5,6 @@ import { hashKey, serializeCacheKey } from './utils' import type { CacheKeyConfig, DefaultError, - DefaultedMutationOptions, MutationFunctionContext, MutationKey, MutationMeta, @@ -94,7 +93,6 @@ export class Mutation< > extends Removable { state: MutationState options!: MutationOptions - cacheKey?: MutationKey mutationHash?: string readonly mutationId: number @@ -117,22 +115,17 @@ export class Mutation< this.state = config.state || getDefaultState() this.setOptions(config.options) - const cacheKey = ( - config.options as DefaultedMutationOptions - )._cacheKey - this.cacheKey = - cacheKey ?? - (config.options.mutationKey - ? serializeCacheKey( - config.options.mutationKey, - this.#mutationCache.config.valueSerializer, - ) - : undefined) + const serializedMutationKey = config.options.mutationKey + ? serializeCacheKey( + config.options.mutationKey, + this.#mutationCache.config.valueSerializer, + ) + : undefined this.mutationHash = config.mutationHash ?? - (this.cacheKey - ? (this.#mutationCache.config.hashFn?.(this.cacheKey) ?? - hashKey(this.cacheKey)) + (serializedMutationKey + ? (this.#mutationCache.config.hashFn?.(serializedMutationKey) ?? + hashKey(serializedMutationKey)) : undefined) this.scheduleGc() } diff --git a/packages/query-core/src/mutationCache.ts b/packages/query-core/src/mutationCache.ts index 5e422507154..a2d16ed0890 100644 --- a/packages/query-core/src/mutationCache.ts +++ b/packages/query-core/src/mutationCache.ts @@ -1,6 +1,6 @@ import { notifyManager } from './notifyManager' import { Mutation } from './mutation' -import { createMutationMatcher, noop } from './utils' +import { matchMutation, noop } from './utils' import { Subscribable } from './subscribable' import type { MutationObserver } from './mutationObserver' import type { @@ -213,8 +213,8 @@ export class MutationCache extends Subscribable { ): Mutation | undefined { const defaultedFilters = { exact: true, ...filters } - return this.getAll().find( - createMutationMatcher(defaultedFilters, this.config), + return this.getAll().find((mutation) => + matchMutation(defaultedFilters, mutation), ) as Mutation | undefined } @@ -224,7 +224,7 @@ export class MutationCache extends Subscribable { return mutations } - return mutations.filter(createMutationMatcher(filters, this.config)) + return mutations.filter((mutation) => matchMutation(filters, mutation)) } notify(event: MutationCacheNotifyEvent) { diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index a0a879678c8..98860841276 100644 --- a/packages/query-core/src/mutationObserver.ts +++ b/packages/query-core/src/mutationObserver.ts @@ -1,11 +1,10 @@ import { getDefaultState } from './mutation' import { notifyManager } from './notifyManager' import { Subscribable } from './subscribable' -import { hashKey, shallowEqualObjects } from './utils' +import { hashKey, serializeCacheKey, shallowEqualObjects } from './utils' import type { QueryClient } from './queryClient' import type { DefaultError, - DefaultedMutationOptions, MutateOptions, MutationFunctionContext, MutationObserverOptions, @@ -72,7 +71,7 @@ export class MutationObserver< >, ) { const prevOptions = this.options as - | DefaultedMutationOptions + | MutationObserverOptions | undefined const defaultedOptions = this.#client.defaultMutationOptions(options) this.options = defaultedOptions @@ -85,12 +84,14 @@ export class MutationObserver< } const hashFn = this.#client.getMutationCache().config.hashFn ?? hashKey + const valueSerializer = + this.#client.getMutationCache().config.valueSerializer if ( prevOptions?.mutationKey && defaultedOptions.mutationKey && - hashFn(prevOptions._cacheKey ?? prevOptions.mutationKey) !== - hashFn(defaultedOptions._cacheKey ?? defaultedOptions.mutationKey) + hashFn(serializeCacheKey(prevOptions.mutationKey, valueSerializer)) !== + hashFn(serializeCacheKey(defaultedOptions.mutationKey, valueSerializer)) ) { this.reset() } else if (this.#currentMutation?.state.status === 'pending') { diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 22e36bb2be0..6f788d3cede 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -4,7 +4,6 @@ import { replaceData, resolveQueryBoolean, resolveStaleTime, - serializeCacheKey, skipToken, timeUntilStale, } from './utils' @@ -41,7 +40,6 @@ interface QueryConfig< TQueryKey extends QueryKey = QueryKey, > { client: QueryClient - cacheKey?: QueryKey queryKey: TQueryKey queryHash: string options?: QueryOptions @@ -161,7 +159,6 @@ export class Query< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > extends Removable { - readonly cacheKey: QueryKey queryKey: TQueryKey queryHash: string options!: QueryOptions @@ -186,9 +183,6 @@ export class Query< this.#cache = this.#client.getQueryCache() this.setOptions(config.options) this.observers = [] - this.cacheKey = - config.cacheKey ?? - serializeCacheKey(config.queryKey, this.#cache.config.valueSerializer) this.queryKey = config.queryKey this.queryHash = config.queryHash this.#initialState = getDefaultState(this.options) diff --git a/packages/query-core/src/queryCache.ts b/packages/query-core/src/queryCache.ts index 47d4754ef1c..7b39830dab1 100644 --- a/packages/query-core/src/queryCache.ts +++ b/packages/query-core/src/queryCache.ts @@ -1,4 +1,4 @@ -import { createQueryMatcher } from './utils' +import { matchQuery } from './utils' import { Query } from './query' import { notifyManager } from './notifyManager' import { Subscribable } from './subscribable' @@ -113,19 +113,17 @@ export class QueryCache extends Subscribable { ): Query { const defaultedOptions = client.defaultQueryOptions(options) const queryKey = defaultedOptions.queryKey - const cacheKey = defaultedOptions._cacheKey const queryHash = defaultedOptions.queryHash let query = this.get(queryHash) if (!query) { query = new Query({ client, - cacheKey, queryKey, queryHash, options: defaultedOptions, state, - defaultOptions: client.getQueryDefaults(queryKey, cacheKey), + defaultOptions: client.getQueryDefaults(queryKey), }) this.add(query) } @@ -188,8 +186,8 @@ export class QueryCache extends Subscribable { ): Query | undefined { const defaultedFilters = { exact: true, ...filters } - return this.getAll().find( - createQueryMatcher(defaultedFilters, this.config), + return this.getAll().find((query) => + matchQuery(defaultedFilters, query), ) as Query | undefined } @@ -199,7 +197,7 @@ export class QueryCache extends Subscribable { return queries } - return queries.filter(createQueryMatcher(filters, this.config)) + return queries.filter((query) => matchQuery(filters, query)) } notify(event: QueryCacheNotifyEvent): void { diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index e96e5644180..62543ebdf3c 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -50,44 +50,46 @@ import type { QueryState } from './query' // TYPES interface QueryDefaults { - cacheKey: CacheKey + queryKey: QueryKey defaultOptions: OmitKeyof, 'queryKey'> } interface MutationDefaults { - cacheKey: CacheKey + mutationKey: MutationKey defaultOptions: MutationOptions } function mergeCacheKeyDefaults( - defaults: Iterable<{ cacheKey: CacheKey; defaultOptions: TOptions }>, - cacheKey: CacheKey, + defaults: Iterable< + | { queryKey: QueryKey; defaultOptions: TOptions } + | { mutationKey: MutationKey; defaultOptions: TOptions } + >, + key: CacheKey, + valueSerializer: CacheKeyConfig['valueSerializer'], ): TOptions { const result = {} as TOptions - for (const cacheKeyDefault of defaults) { - if (partialMatchKey(cacheKey, cacheKeyDefault.cacheKey)) { - Object.assign(result, cacheKeyDefault.defaultOptions) + for (const keyDefault of defaults) { + const defaultKey = + 'queryKey' in keyDefault ? keyDefault.queryKey : keyDefault.mutationKey + if (partialMatchKey(key, defaultKey, valueSerializer)) { + Object.assign(result, keyDefault.defaultOptions) } } return result } -function serializeAndHashCacheKey( +function hashCacheKey( cacheKey: TCacheKey, config: Readonly>, -): { cacheKey: CacheKey; queryHash: string } { +): string { const serializedCacheKey = serializeCacheKey( cacheKey, config.valueSerializer, ) as TCacheKey - return { - cacheKey: serializedCacheKey, - queryHash: - config.hashFn?.(serializedCacheKey) ?? hashKey(serializedCacheKey), - } + return config.hashFn?.(serializedCacheKey) ?? hashKey(serializedCacheKey) } // CLASS @@ -594,24 +596,21 @@ export class QueryClient { > >, ): void { - const { cacheKey, queryHash } = serializeAndHashCacheKey( - queryKey, - this.#queryCache.config, - ) + const queryHash = hashCacheKey(queryKey, this.#queryCache.config) this.#queryDefaults.set(queryHash, { - cacheKey, + queryKey, defaultOptions: options, }) } getQueryDefaults( queryKey: QueryKey, - _cacheKey?: QueryKey, ): OmitKeyof, 'queryKey'> { - const cacheKey = - _cacheKey ?? - serializeCacheKey(queryKey, this.#queryCache.config.valueSerializer) - return mergeCacheKeyDefaults(this.#queryDefaults.values(), cacheKey) + return mergeCacheKeyDefaults( + this.#queryDefaults.values(), + queryKey, + this.#queryCache.config.valueSerializer, + ) } setMutationDefaults< @@ -626,12 +625,9 @@ export class QueryClient { 'mutationKey' >, ): void { - const { cacheKey, queryHash } = serializeAndHashCacheKey( - mutationKey, - this.#mutationCache.config, - ) + const queryHash = hashCacheKey(mutationKey, this.#mutationCache.config) this.#mutationDefaults.set(queryHash, { - cacheKey, + mutationKey, defaultOptions: options, }) } @@ -639,11 +635,11 @@ export class QueryClient { getMutationDefaults( mutationKey: MutationKey, ): OmitKeyof, 'mutationKey'> { - const cacheKey = serializeCacheKey( + return mergeCacheKeyDefaults( + this.#mutationDefaults.values(), mutationKey, this.#mutationCache.config.valueSerializer, ) - return mergeCacheKeyDefaults(this.#mutationDefaults.values(), cacheKey) } defaultQueryOptions< @@ -696,10 +692,10 @@ export class QueryClient { ...this.#defaultOptions.queries, ...mergeCacheKeyDefaults( this.#queryDefaults.values(), - serializedCacheKey, + options.queryKey, + this.#queryCache.config.valueSerializer, ), ...options, - _cacheKey: serializedCacheKey, _defaulted: true, } @@ -740,24 +736,16 @@ export class QueryClient { if (options?._defaulted) { return options as DefaultedMutationOptions } - const serializedCacheKey = options?.mutationKey - ? serializeCacheKey( - options.mutationKey, - this.#mutationCache.config.valueSerializer, - ) - : undefined return { ...this.#defaultOptions.mutations, ...(options?.mutationKey && mergeCacheKeyDefaults( this.#mutationDefaults.values(), - serializedCacheKey!, + options.mutationKey, + this.#mutationCache.config.valueSerializer, )), ...options, - ...(serializedCacheKey && { - _cacheKey: serializedCacheKey, - }), _defaulted: true, } as DefaultedMutationOptions } diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 1c88b68a1a2..54237c27e92 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -208,9 +208,9 @@ export interface CacheKeyConfig { */ readonly hashFn?: CacheKeyHashFunction /** - * Serializes cache key values. The serializer must be idempotent because a - * key can be serialized more than once. The serialized key is used - * internally for hashing, matching, dehydration, and persistence. + * Serializes cache key values. The serializer must be deterministic and + * idempotent because a key can be serialized more than once. Query keys must + * not be changed after they are used. */ readonly valueSerializer?: CacheKeyValueSerializer } @@ -478,7 +478,6 @@ export type DefaultedQueryObserverOptions< QueryObserverOptions, 'throwOnError' | 'refetchOnReconnect' | 'queryHash' > & { - _cacheKey: QueryKey _defaulted: true } @@ -516,7 +515,6 @@ export type DefaultedInfiniteQueryObserverOptions< >, 'throwOnError' | 'refetchOnReconnect' | 'queryHash' > & { - _cacheKey: QueryKey _defaulted: true } @@ -1179,7 +1177,6 @@ export interface MutationOptions< export type DefaultedMutationOptions< TOptions extends MutationOptions, > = TOptions & { - _cacheKey?: MutationKey _defaulted: true } diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index a5da52678a7..ab2eab0bc75 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,7 +1,6 @@ import { timeoutManager } from './timeoutManager' import type { CacheKey, - CacheKeyConfig, CacheKeyValueSerializer, DefaultError, FetchStatus, @@ -147,38 +146,32 @@ export function matchQuery( filters: QueryFilters, query: Query, ): boolean { - return createQueryMatcher(filters, query.cacheKeyConfig)(query) -} - -export function createQueryMatcher( - filters: QueryFilters, - config: Readonly>, -): (query: Query) => boolean { - const cacheKeyMatcher = createCacheKeyMatcher( - filters.queryKey, - filters.exact, - config, - ) - - return (query) => matchQueryWithMatcher(filters, query, cacheKeyMatcher) -} - -function matchQueryWithMatcher( - filters: QueryFilters, - query: Query, - cacheKeyMatcher?: CacheKeyMatcher, -): boolean { - const { type = 'all', fetchStatus, predicate, queryKey, stale } = filters - - if ( - queryKey && - !cacheKeyMatcher?.( - query.cacheKey, - query.queryHash, - query.options.queryKeyHashFn, - ) - ) { - return false + const { + type = 'all', + exact, + fetchStatus, + predicate, + queryKey, + stale, + } = filters + + if (queryKey) { + const config = query.cacheKeyConfig + const serializedKey = serializeCacheKey(queryKey, config.valueSerializer) + + if (exact) { + const queryHash = + config.hashFn?.(serializedKey) ?? + query.options.queryKeyHashFn?.(serializedKey) ?? + hashKey(serializedKey) + if (query.queryHash !== queryHash) { + return false + } + } else if ( + !partialMatchKey(query.queryKey, queryKey, config.valueSerializer) + ) { + return false + } } if (type !== 'all') { @@ -210,34 +203,27 @@ export function matchMutation( filters: MutationFilters, mutation: Mutation, ): boolean { - return createMutationMatcher(filters, mutation.cacheKeyConfig)(mutation) -} - -export function createMutationMatcher( - filters: MutationFilters, - config: Readonly>, -): (mutation: Mutation) => boolean { - const cacheKeyMatcher = createCacheKeyMatcher( - filters.mutationKey, - filters.exact, - config, - ) + const { exact, status, predicate, mutationKey } = filters + if (mutationKey) { + if (!mutation.options.mutationKey) { + return false + } - return (mutation) => - matchMutationWithMatcher(filters, mutation, cacheKeyMatcher) -} + const config = mutation.cacheKeyConfig + const serializedKey = serializeCacheKey(mutationKey, config.valueSerializer) -function matchMutationWithMatcher( - filters: MutationFilters, - mutation: Mutation, - cacheKeyMatcher?: CacheKeyMatcher, -): boolean { - const { status, predicate, mutationKey } = filters - if (mutationKey) { - if ( - !mutation.options.mutationKey || - !mutation.cacheKey || - !cacheKeyMatcher?.(mutation.cacheKey, mutation.mutationHash) + if (exact) { + const mutationHash = + config.hashFn?.(serializedKey) ?? hashKey(serializedKey) + if (mutation.mutationHash !== mutationHash) { + return false + } + } else if ( + !partialMatchKey( + mutation.options.mutationKey, + mutationKey, + config.valueSerializer, + ) ) { return false } @@ -254,12 +240,6 @@ function matchMutationWithMatcher( return true } -type CacheKeyMatcher = ( - cacheKey: CacheKey, - cacheHash: string | undefined, - hashFn?: (cacheKey: unknown) => string, -) => boolean - /** * Default cache key hash function. * Hashes the value into a stable hash. @@ -277,6 +257,11 @@ export function hashKey(cacheKey: CacheKey): string { ) } +const cacheKeySerializationCache = new WeakMap< + CacheKeyValueSerializer, + WeakMap +>() + function serializeCacheKeyValue( value: unknown, serializer: CacheKeyValueSerializer | undefined, @@ -337,37 +322,20 @@ export function serializeCacheKey( return key } - return serializeCacheKeyValue(key, serializer) as CacheKey -} - -function createCacheKeyMatcher( - cacheKey: TCacheKey | undefined, - exact: boolean | undefined, - config: Readonly>, -): CacheKeyMatcher | undefined { - if (!cacheKey) { - return undefined + let serializerCache = cacheKeySerializationCache.get(serializer) + if (!serializerCache) { + serializerCache = new WeakMap() + cacheKeySerializationCache.set(serializer, serializerCache) } - const serializedKey = serializeCacheKey( - cacheKey, - config.valueSerializer, - ) as TCacheKey - - if (exact) { - if (config.hashFn) { - const hash = config.hashFn(serializedKey) - - return (_key, cacheHash) => cacheHash === hash - } - - const defaultHash = hashKey(serializedKey) - - return (_key, cacheHash, hashFn) => - cacheHash === (hashFn?.(serializedKey) ?? defaultHash) + const cachedKey = serializerCache.get(key) + if (cachedKey) { + return cachedKey } - return (key) => partialMatchKey(key, serializedKey) + const serializedKey = serializeCacheKeyValue(key, serializer) as CacheKey + serializerCache.set(key, serializedKey) + return serializedKey } /** diff --git a/packages/query-persist-client-core/src/__tests__/createPersister.test.ts b/packages/query-persist-client-core/src/__tests__/createPersister.test.ts index f4f3c1e2319..40fa963373e 100644 --- a/packages/query-persist-client-core/src/__tests__/createPersister.test.ts +++ b/packages/query-persist-client-core/src/__tests__/createPersister.test.ts @@ -5,7 +5,10 @@ import { experimental_createQueryPersister, } from '../createPersister' import type { QueryFunctionContext, QueryKey } from '@tanstack/query-core' -import type { StoragePersisterOptions } from '../createPersister' +import type { + PersistedQuery, + StoragePersisterOptions, +} from '../createPersister' function getFreshStorage() { const storage = new Map() @@ -456,6 +459,45 @@ describe('createPersister', () => { status: 'success', }, }) + expect(JSON.parse(await storage.getItem(storageKey))).not.toHaveProperty( + 'serializedQueryKey', + ) + }) + + it('should persist and restore the original query key', async () => { + const storage = getFreshStorage() + const valueSerializer = vi.fn((value: unknown) => + value instanceof Map ? [...value.entries()] : value, + ) + const client = new QueryClient({ + queryCache: new QueryCache({ valueSerializer }), + }) + const persister = experimental_createQueryPersister({ + storage, + serialize: (value) => value, + deserialize: (value) => value as PersistedQuery, + }) + const queryKey = ['maps', new Map([['a', 1]])] + + client.setQueryData(queryKey, 'data') + const query = client.getQueryCache().getAll()[0]! + valueSerializer.mockClear() + await persister.persistQuery(query) + expect(valueSerializer).not.toHaveBeenCalled() + + const persisted = await storage.getItem( + `${PERSISTER_KEY_PREFIX}-${query.queryHash}`, + ) + expect(persisted).toMatchObject({ + queryKey, + }) + expect(persisted).not.toHaveProperty('serializedQueryKey') + + client.clear() + await persister.restoreQueries(client, { queryKey: ['maps'] }) + + expect(client.getQueryCache().getAll()[0]?.queryKey).toBe(queryKey) + expect(client.getQueryData(queryKey)).toBe('data') }) it('should skip persistence if storage is not provided', async () => { @@ -654,6 +696,36 @@ describe('createPersister', () => { expect(client.getQueryData(queryKey)).toBe('foo') }) + it('should support legacy entries with normalized query keys', async () => { + const storage = getFreshStorage() + const valueSerializer = (value: unknown) => + value instanceof Map ? [...value.entries()] : value + const client = new QueryClient({ + queryCache: new QueryCache({ valueSerializer }), + }) + const queryKey = ['maps', [['a', 1]]] + const queryHash = JSON.stringify(queryKey) + const persister = experimental_createQueryPersister({ + storage, + serialize: (value) => value, + deserialize: (value) => value as PersistedQuery, + }) + + await storage.setItem(`${PERSISTER_KEY_PREFIX}-${queryHash}`, { + buster: '', + queryHash, + queryKey, + state: { + data: 'data', + dataUpdatedAt: Date.now(), + }, + }) + + await persister.restoreQueries(client, { queryKey: ['maps'] }) + + expect(client.getQueryData(queryKey)).toBe('data') + }) + it('should properly clean storage from busted entries', async () => { const storage = getFreshStorage() const { persister, client, query, queryKey } = setupPersister(['foo'], { diff --git a/packages/query-persist-client-core/src/createPersister.ts b/packages/query-persist-client-core/src/createPersister.ts index 890273dab4e..ff1232ed1dc 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -2,6 +2,7 @@ import { matchQuery, notifyManager, partialMatchKey, + serializeCacheKey, } from '@tanstack/query-core' import type { Query, @@ -15,6 +16,7 @@ import type { export interface PersistedQuery { buster: string queryHash: string + /** The original public query key. */ queryKey: QueryKey state: QueryState } @@ -129,10 +131,12 @@ export function experimental_createQueryPersister({ return {} } - const options = queryClient.defaultQueryOptions({ queryKey }) return { - cacheKey: options._cacheKey, - cacheHash: options.queryHash, + filterKey: serializeCacheKey( + queryKey, + queryClient.getQueryCache().config.valueSerializer, + ), + queryHash: queryClient.defaultQueryOptions({ queryKey }).queryHash, } } @@ -206,7 +210,7 @@ export function experimental_createQueryPersister({ storageKey, await serialize({ state: query.state, - queryKey: query.cacheKey, + queryKey: query.queryKey, queryHash: query.queryHash, buster: buster, }), @@ -289,7 +293,7 @@ export function experimental_createQueryPersister({ queryFilters: Pick = {}, ): Promise { const { exact, queryKey } = queryFilters - const { cacheKey, cacheHash } = getCacheKeyFilter(queryClient, queryKey) + const { filterKey, queryHash } = getCacheKeyFilter(queryClient, queryKey) if (storage?.entries) { const storageKeyPrefix = `${prefix}-` @@ -310,10 +314,18 @@ export function experimental_createQueryPersister({ if (queryKey) { if (exact) { - if (persistedQuery.queryHash !== cacheHash) { + if (persistedQuery.queryHash !== queryHash) { continue } - } else if (!partialMatchKey(persistedQuery.queryKey, cacheKey!)) { + } else if ( + !partialMatchKey( + serializeCacheKey( + persistedQuery.queryKey, + queryClient.getQueryCache().config.valueSerializer, + ), + filterKey!, + ) + ) { continue } } @@ -339,7 +351,7 @@ export function experimental_createQueryPersister({ queryFilters: Pick = {}, ): Promise { const { exact, queryKey } = queryFilters - const { cacheKey, cacheHash } = getCacheKeyFilter(queryClient, queryKey) + const { filterKey, queryHash } = getCacheKeyFilter(queryClient, queryKey) if (storage?.entries) { const entries = await storage.entries() @@ -360,10 +372,18 @@ export function experimental_createQueryPersister({ } if (exact) { - if (persistedQuery.queryHash !== cacheHash) { + if (persistedQuery.queryHash !== queryHash) { continue } - } else if (!partialMatchKey(persistedQuery.queryKey, cacheKey!)) { + } else if ( + !partialMatchKey( + serializeCacheKey( + persistedQuery.queryKey, + queryClient.getQueryCache().config.valueSerializer, + ), + filterKey!, + ) + ) { continue } From d6379774e74ea05610696c7647bfc3fd7a39b255 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 21 Aug 2026 14:12:16 +0200 Subject: [PATCH 05/10] smol cleanup --- .../react/plugins/createPersister.md | 14 ++++---- .../src/__tests__/hydration.test.tsx | 36 ------------------- packages/query-core/src/queryObserver.ts | 24 ++----------- .../src/__tests__/Devtools.test.tsx | 29 +++++---------- .../src/__tests__/utils.test.ts | 17 +++------ 5 files changed, 22 insertions(+), 98 deletions(-) diff --git a/docs/framework/react/plugins/createPersister.md b/docs/framework/react/plugins/createPersister.md index 8fcf3c030d2..98b384e716d 100644 --- a/docs/framework/react/plugins/createPersister.md +++ b/docs/framework/react/plugins/createPersister.md @@ -75,7 +75,7 @@ Invoking `experimental_createQueryPersister` returns additional utilities in add ### `persistQueryByKey(queryKey: QueryKey, queryClient: QueryClient): Promise` -This function will persist `Query` to storage and key defined when creating persister. +This function will persist `Query` to storage and key defined when creating persister. This utility might be used along `setQueryData` to persist optimistic update to storage without waiting for invalidation. ```tsx @@ -101,19 +101,19 @@ useMutation({ ### `retrieveQuery(queryHash: string): Promise` -This function would attempt to retrieve persisted query by `queryHash`. +This function would attempt to retrieve persisted query by `queryHash`. If `query` is `expired`, `busted` or `malformed` it would be removed from the storage instead, and `undefined` would be returned. ### `persisterGc(): Promise` This function can be used to sporadically clean up storage from `expired`, `busted` or `malformed` entries. -For this function to work, your storage must expose `entries` method that would return a `key-value tuple array`. +For this function to work, your storage must expose `entries` method that would return a `key-value tuple array`. For example `Object.entries(localStorage)` for `localStorage` or `entries` from `idb-keyval`. ### `restoreQueries(queryClient: QueryClient, filters): Promise` -This function can be used to restore queries that are currently stored by persister. +This function can be used to restore queries that are currently stored by persister. For example when your app is starting up in offline mode, or you want all or only specific data from previous session to be immediately available without intermediate `loading` state. The filter object supports the following properties: @@ -123,7 +123,7 @@ The filter object supports the following properties: - `exact?: boolean` - If you don't want to search queries inclusively by query key, you can pass the `exact: true` option to return only the query with the exact query key you have passed. -For this function to work, your storage must expose `entries` method that would return a `key-value tuple array`. +For this function to work, your storage must expose `entries` method that would return a `key-value tuple array`. For example `Object.entries(localStorage)` for `localStorage` or `entries` from `idb-keyval`. ### `removeQueries(queryClient: QueryClient, filters?): Promise` @@ -131,8 +131,6 @@ For example `Object.entries(localStorage)` for `localStorage` or `entries` from When using `queryClient.removeQueries`, the data remains in the persister and needs to be removed separately. This function can be used to remove queries that are currently stored by persister. -The `queryClient` is required so the persister can use its query key hashing, value serialization, and matching configuration. - The filter object supports the following properties: - `queryKey?: QueryKey` @@ -140,7 +138,7 @@ The filter object supports the following properties: - `exact?: boolean` - If you don't want to search queries inclusively by query key, you can pass the `exact: true` option to return only the query with the exact query key you have passed. -For this function to work, your storage must expose `entries` method that would return a `key-value tuple array`. +For this function to work, your storage must expose `entries` method that would return a `key-value tuple array`. For example `Object.entries(localStorage)` for `localStorage` or `entries` from `idb-keyval`. ## API diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 3968547c37a..389226bd5b6 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -113,42 +113,6 @@ describe('dehydration and rehydration', () => { hydrationClient.clear() }) - it('should preserve original keys during dehydration and hydration', () => { - const serializeValue = (value: unknown) => - value instanceof Date ? value.toISOString() : value - const serverClient = new QueryClient({ - queryCache: new QueryCache({ valueSerializer: serializeValue }), - mutationCache: new MutationCache({ valueSerializer: serializeValue }), - }) - const key = ['dates', new Date(0)] - - serverClient.setQueryData(key, 'data') - serverClient.getMutationCache().build(serverClient, { - mutationKey: key, - mutationFn: () => Promise.resolve('data'), - }) - - const dehydrated = dehydrate(serverClient, { - shouldDehydrateMutation: () => true, - }) - expect(dehydrated.queries[0]?.queryKey).toEqual(['dates', new Date(0)]) - expect(dehydrated.mutations[0]?.mutationKey).toEqual(['dates', new Date(0)]) - - const valueSerializer = vi.fn(serializeValue) - const client = new QueryClient({ - queryCache: new QueryCache({ valueSerializer }), - mutationCache: new MutationCache({ valueSerializer }), - }) - hydrate(client, dehydrated) - - expect(valueSerializer).toHaveBeenCalled() - expect(client.getQueryData(key)).toBe('data') - expect(client.getQueryCache().getAll()[0]?.queryKey).toEqual(key) - expect(client.getMutationCache().getAll()[0]?.options.mutationKey).toEqual( - key, - ) - }) - it('should not dehydrate queries if dehydrateQueries is set to false', async () => { const key = queryKey() const queryCache = new QueryCache() diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 0df37a285b0..36ab6e9efe4 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -43,13 +43,6 @@ export class QueryObserver< TQueryKey extends QueryKey = QueryKey, > extends Subscribable> { #client: QueryClient - public options!: DefaultedQueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - > #currentQuery: Query = undefined! #currentQueryInitialState: QueryState = undefined! #currentResult: QueryObserverResult = undefined! @@ -74,7 +67,7 @@ export class QueryObserver< constructor( client: QueryClient, - options: QueryObserverOptions< + public options: QueryObserverOptions< TQueryFnData, TError, TData, @@ -86,13 +79,6 @@ export class QueryObserver< this.#client = client this.#selectError = null - this.options = options as DefaultedQueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - > this.bindMethods() this.setOptions(options) @@ -154,13 +140,7 @@ export class QueryObserver< TQueryKey >, ): void { - const prevOptions = this.options as QueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - > + const prevOptions = this.options const prevQuery = this.#currentQuery this.options = this.#client.defaultQueryOptions(options) diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index b1876d00131..f73024502c6 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - QueryCache, QueryClient, QueryObserver, dehydrate, @@ -319,14 +318,10 @@ describe('Devtools', () => { }) it('should render a query row when a hydrated query uses a custom hash function', async () => { - queryClient = new QueryClient({ - queryCache: new QueryCache({ - hashFn: () => 'custom-posts-hash', - }), - }) queryClient.fetchQuery({ queryKey: ['posts'], queryFn: () => [{ id: 1 }], + queryKeyHashFn: () => 'custom-posts-hash', }) await vi.advanceTimersByTimeAsync(0) const dehydratedState = dehydrate(queryClient) @@ -389,13 +384,10 @@ describe('Devtools', () => { describe('disabled and static queries', () => { it('should mark a disabled query in the row label', () => { - const observer = queryClient.getQueryCache().build( - queryClient, - queryClient.defaultQueryOptions({ - queryKey: ['disabled-q'], - queryFn: () => 'x', - }), - ) + const observer = queryClient.getQueryCache().build(queryClient, { + queryKey: ['disabled-q'], + queryFn: () => 'x', + }) observer.setOptions({ ...observer.options, enabled: false, @@ -407,13 +399,10 @@ describe('Devtools', () => { }) it('should render a "static" indicator for a query with "staleTime: \'static\'"', () => { - const query = queryClient.getQueryCache().build( - queryClient, - queryClient.defaultQueryOptions({ - queryKey: ['static-q'], - queryFn: () => 'x', - }), - ) + const query = queryClient.getQueryCache().build(queryClient, { + queryKey: ['static-q'], + queryFn: () => 'x', + }) const observer = new QueryObserver(queryClient, { queryKey: ['static-q'], queryFn: () => 'x', diff --git a/packages/query-devtools/src/__tests__/utils.test.ts b/packages/query-devtools/src/__tests__/utils.test.ts index 409115a9b2c..496594d63cd 100644 --- a/packages/query-devtools/src/__tests__/utils.test.ts +++ b/packages/query-devtools/src/__tests__/utils.test.ts @@ -1077,9 +1077,7 @@ describe('Utils tests', () => { queryKey: ReadonlyArray, state?: Partial, ): Query { - const query = queryClient - .getQueryCache() - .build(queryClient, queryClient.defaultQueryOptions({ queryKey })) + const query = queryClient.getQueryCache().build(queryClient, { queryKey }) if (state) { query.setState(state) } @@ -1451,9 +1449,7 @@ describe('Utils tests', () => { queryKey: QueryKey, state?: Partial, ): Query { - const query = queryClient - .getQueryCache() - .build(queryClient, queryClient.defaultQueryOptions({ queryKey })) + const query = queryClient.getQueryCache().build(queryClient, { queryKey }) if (state) { query.setState(state) } @@ -1565,12 +1561,9 @@ describe('Utils tests', () => { let queryClient: QueryClient function makeState(fetchStatus: FetchStatus): Query['state'] { - const query = queryClient - .getQueryCache() - .build( - queryClient, - queryClient.defaultQueryOptions({ queryKey: [fetchStatus] }), - ) + const query = queryClient.getQueryCache().build(queryClient, { + queryKey: [fetchStatus], + }) query.setState({ fetchStatus }) return query.state } From 68afaa5c075886bc34208cd5a2e1a063570529de Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 21 Aug 2026 14:26:22 +0200 Subject: [PATCH 06/10] simplify serializeCacheKeyValue --- .../query-core/src/__tests__/utils.test.tsx | 49 +++++++++++++++++++ packages/query-core/src/utils.ts | 28 +++++------ 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/packages/query-core/src/__tests__/utils.test.tsx b/packages/query-core/src/__tests__/utils.test.tsx index c960212aef2..e30b163749a 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -70,6 +70,55 @@ describe('core/utils', () => { expect(serializer).toHaveBeenCalledTimes(4) }) + it('serializes recursive objects and arrays with copy-on-write', () => { + const serializer = vi.fn((value: unknown) => + typeof value === 'number' ? String(value) : value, + ) + const untouched = { status: 'active' } + const nestedObject = { page: 1, filters: { limit: 2 }, untouched } + const nestedArray = [{ offset: 3 }, untouched] + const key = ['todos', nestedObject, nestedArray] + + const serialized = serializeCacheKey(key, serializer) + + expect(serialized).toEqual([ + 'todos', + { page: '1', filters: { limit: '2' }, untouched }, + [{ offset: '3' }, untouched], + ]) + expect(serialized).not.toBe(key) + expect(serialized[1]).not.toBe(nestedObject) + expect((serialized[1] as Record).untouched).toBe( + untouched, + ) + expect(serialized[2]).not.toBe(nestedArray) + expect((serialized[2] as Array)[1]).toBe(untouched) + }) + + it('preserves unchanged objects', () => { + const serializer = vi.fn((value: unknown) => value) + const nestedObject = { status: 'active', tags: ['one', 'two'] } + const key = ['todos', nestedObject] + + const serialized = serializeCacheKey(key, serializer) + + expect(serialized).toBe(key) + expect(serialized[1]).toBe(nestedObject) + expect(serializer).toHaveBeenCalledTimes(4) + }) + + it('recursively serializes plain objects returned by the serializer', () => { + const serializer = vi.fn((value: unknown) => + value instanceof Date ? { timestamp: value.getTime() } : value, + ) + const key = ['dates', new Date(0)] + + const serialized = serializeCacheKey(key, serializer) + + expect(serialized).toEqual(['dates', { timestamp: 0 }]) + expect(serialized).not.toBe(key) + }) + it('does not cache failed serialization', () => { const serializer = vi.fn(() => { throw new Error('serialize failed') diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index ab2eab0bc75..ea1018d48f2 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -264,44 +264,40 @@ const cacheKeySerializationCache = new WeakMap< function serializeCacheKeyValue( value: unknown, - serializer: CacheKeyValueSerializer | undefined, + serializer: CacheKeyValueSerializer, ): unknown { - if (Array.isArray(value)) { - let result: Array | undefined + if (isPlainArray(value)) { + let result = value for (let index = 0; index < value.length; index++) { - if (!(index in value)) { - continue - } - const item = value[index] const serializedItem = serializeCacheKeyValue(item, serializer) if (serializedItem !== item) { - result ??= value.slice() + if (result === value) { + result = value.slice() + } result[index] = serializedItem } } - return result ?? value + return result } if (isPlainObject(value)) { - let result: Record | undefined + let result = value for (const key of Object.keys(value)) { const item = value[key] const serializedItem = serializeCacheKeyValue(item, serializer) if (serializedItem !== item) { - result ??= { ...value } + if (result === value) { + result = { ...value } + } result[key] = serializedItem } } - return result ?? value - } - - if (!serializer) { - return value + return result } const serializedValue = serializer(value) From a700c60ba9ff7156687303c898380e7fbb26a884 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 21 Aug 2026 14:37:18 +0200 Subject: [PATCH 07/10] fix vue --- packages/vue-query/src/__tests__/queryClient.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/vue-query/src/__tests__/queryClient.test.ts b/packages/vue-query/src/__tests__/queryClient.test.ts index 35c22501a77..7a9cbeb1ded 100644 --- a/packages/vue-query/src/__tests__/queryClient.test.ts +++ b/packages/vue-query/src/__tests__/queryClient.test.ts @@ -1,7 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ref, unref } from 'vue-demi' -import { QueryClient as QueryClientOrigin } from '@tanstack/query-core' -import { ref } from 'vue-demi' import { QueryCache, QueryClient as QueryClientOrigin, From 1256c68057559b775a12a1622cfa4bce47e1eabc Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 21 Aug 2026 15:07:12 +0200 Subject: [PATCH 08/10] lit --- packages/lit-query/src/tests/queries-controller.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/lit-query/src/tests/queries-controller.test.ts b/packages/lit-query/src/tests/queries-controller.test.ts index f651b2b42a8..36e6eb20d4e 100644 --- a/packages/lit-query/src/tests/queries-controller.test.ts +++ b/packages/lit-query/src/tests/queries-controller.test.ts @@ -352,7 +352,9 @@ describe('createQueriesController', () => { const originalDefaultQueryOptions = client.defaultQueryOptions let defaultQueryOptionsCalls = 0 client.defaultQueryOptions = ((options) => { - defaultQueryOptionsCalls += 1 + if (!options._defaulted) { + defaultQueryOptionsCalls += 1 + } return originalDefaultQueryOptions.call(client, options as never) }) as typeof client.defaultQueryOptions From 033aa7c2bdb8a69902aab6547672a88b1b7bbfa9 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 21 Aug 2026 18:20:52 +0200 Subject: [PATCH 09/10] simplifictation --- .../src/__tests__/hydration.test.tsx | 31 ++++++++ .../src/__tests__/mutationCache.test.tsx | 14 +++- .../src/__tests__/queryClient.test.tsx | 21 +++-- .../query-core/src/__tests__/utils.test.tsx | 78 ++++++++++++------- packages/query-core/src/mutation.ts | 15 ---- packages/query-core/src/mutationObserver.ts | 10 +-- packages/query-core/src/queryClient.ts | 76 ++++++------------ packages/query-core/src/utils.ts | 78 +++++++++---------- .../src/createPersister.ts | 41 ++++------ 9 files changed, 183 insertions(+), 181 deletions(-) diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 389226bd5b6..4fd8ae3eb56 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1906,4 +1906,35 @@ describe('dehydration and rehydration', () => { clientQueryClient.clear() serverQueryClient.clear() }) + + it('should hydrate a key that needs serialization', () => { + const makeClient = () => + new QueryClient({ + queryCache: new QueryCache({ + valueSerializer: (value) => + value instanceof Date ? value.toISOString() : value, + }), + }) + + const serverQueryClient = makeClient() + serverQueryClient.setQueryData(['events', new Date(0)], 'data') + const dehydrated = JSON.parse(JSON.stringify(dehydrate(serverQueryClient))) + + const clientQueryClient = makeClient() + hydrate(clientQueryClient, dehydrated) + + expect(clientQueryClient.getQueryData(['events', new Date(0)])).toBe('data') + expect(clientQueryClient.getQueryCache().getAll()).toHaveLength(1) + expect( + clientQueryClient.getQueryCache().findAll({ queryKey: ['events'] }), + ).toHaveLength(1) + expect( + clientQueryClient + .getQueryCache() + .findAll({ queryKey: ['events', new Date(0)], exact: true }), + ).toHaveLength(1) + + clientQueryClient.clear() + serverQueryClient.clear() + }) }) diff --git a/packages/query-core/src/__tests__/mutationCache.test.tsx b/packages/query-core/src/__tests__/mutationCache.test.tsx index 23000a8772c..9001604d9e1 100644 --- a/packages/query-core/src/__tests__/mutationCache.test.tsx +++ b/packages/query-core/src/__tests__/mutationCache.test.tsx @@ -375,20 +375,26 @@ describe('mutationCache', () => { valueSerializer.mockClear() hashFn.mockClear() + // `find` defaults to `exact`, so it hashes the filter key and the key of + // each mutation until it finds a match. Serialized keys are memoized per + // key reference, thus the filter key is serialized only once. expect(testCache.find({ mutationKey: ['number', date] })).toBe( numberMutation, ) - expect(valueSerializer).toHaveBeenCalledTimes(2) - expect(hashFn).toHaveBeenCalledTimes(2) + expect(valueSerializer).toHaveBeenCalledTimes(6) + expect(hashFn).toHaveBeenCalledTimes(4) valueSerializer.mockClear() hashFn.mockClear() + // `findAll` examines all three mutations. The first two keys are memoized + // from the `find` above, so only the third key and the new filter key are + // serialized. expect( testCache.findAll({ mutationKey: ['number', date], exact: true }), ).toEqual([numberMutation]) - expect(valueSerializer).toHaveBeenCalledTimes(2) - expect(hashFn).toHaveBeenCalledTimes(3) + expect(valueSerializer).toHaveBeenCalledTimes(4) + expect(hashFn).toHaveBeenCalledTimes(6) stringClient.clear() }) diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index b0b09749641..0fff6c945b6 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -233,9 +233,8 @@ describe('queryClient', () => { expect(options.queryHash).toBe('["counts",[["total","1"]]]') }) - it('should preserve unchanged key branches while serializing', () => { - const unchanged = { status: 'active' } - const key = ['todos', unchanged, new Date(0)] + it('should not change the original key while serializing', () => { + const key = ['todos', { status: 'active' }, new Date(0)] const testClient = new QueryClient({ queryCache: new QueryCache({ valueSerializer: (value) => @@ -248,9 +247,12 @@ describe('queryClient', () => { testClient.getQueryCache().config.valueSerializer, ) - expect(cacheKey).not.toBe(key) - expect(cacheKey[1]).toBe(unchanged) - expect(cacheKey[2]).toBe(new Date(0).toISOString()) + expect(cacheKey).toEqual([ + 'todos', + { status: 'active' }, + '1970-01-01T00:00:00.000Z', + ]) + expect(key).toEqual(['todos', { status: 'active' }, new Date(0)]) }) it('should use the cache hash function for query identity', () => { @@ -355,8 +357,10 @@ describe('queryClient', () => { mutationFn: () => Promise.resolve(), }) - expect(valueSerializer).toHaveBeenCalledTimes(4) - expect(hashFn).toHaveBeenCalledTimes(1) + // building a mutation does not hash its key, only filters need the hash + expect(valueSerializer).not.toHaveBeenCalled() + expect(hashFn).not.toHaveBeenCalled() + const mutation = testClient.getMutationCache().getAll()[0] expect(mutation?.options.mutationKey).toEqual([ 'maps', @@ -368,6 +372,7 @@ describe('queryClient', () => { mutationKey: ['maps', new Map([['a', 1]])], }).length, ).toBe(1) + expect(valueSerializer).toHaveBeenCalled() expect( testClient.getMutationCache().findAll({ mutationKey: ['maps', new Map([['a', 2]])], diff --git a/packages/query-core/src/__tests__/utils.test.tsx b/packages/query-core/src/__tests__/utils.test.tsx index e30b163749a..2f4923d7927 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -70,41 +70,49 @@ describe('core/utils', () => { expect(serializer).toHaveBeenCalledTimes(4) }) - it('serializes recursive objects and arrays with copy-on-write', () => { + it('serializes nested objects and arrays recursively', () => { const serializer = vi.fn((value: unknown) => typeof value === 'number' ? String(value) : value, ) const untouched = { status: 'active' } - const nestedObject = { page: 1, filters: { limit: 2 }, untouched } - const nestedArray = [{ offset: 3 }, untouched] - const key = ['todos', nestedObject, nestedArray] + const key = [ + 'todos', + { page: 1, filters: { limit: 2 }, untouched }, + [{ offset: 3 }, untouched], + ] const serialized = serializeCacheKey(key, serializer) expect(serialized).toEqual([ 'todos', - { page: '1', filters: { limit: '2' }, untouched }, - [{ offset: '3' }, untouched], + { + page: '1', + filters: { limit: '2' }, + untouched: { status: 'active' }, + }, + [{ offset: '3' }, { status: 'active' }], ]) expect(serialized).not.toBe(key) - expect(serialized[1]).not.toBe(nestedObject) - expect((serialized[1] as Record).untouched).toBe( - untouched, - ) - expect(serialized[2]).not.toBe(nestedArray) - expect((serialized[2] as Array)[1]).toBe(untouched) + expect(key).toEqual([ + 'todos', + { page: 1, filters: { limit: 2 }, untouched: { status: 'active' } }, + [{ offset: 3 }, { status: 'active' }], + ]) }) - it('preserves unchanged objects', () => { + it('serializes every leaf value of the key', () => { const serializer = vi.fn((value: unknown) => value) - const nestedObject = { status: 'active', tags: ['one', 'two'] } - const key = ['todos', nestedObject] + const key = ['todos', { status: 'active', tags: ['one', 'two'] }] const serialized = serializeCacheKey(key, serializer) - expect(serialized).toBe(key) - expect(serialized[1]).toBe(nestedObject) - expect(serializer).toHaveBeenCalledTimes(4) + expect(serialized).toEqual(key) + expect(serializer.mock.calls.flat()).toEqual([ + 'todos', + 'active', + 'one', + 'two', + ]) }) it('recursively serializes plain objects returned by the serializer', () => { @@ -119,6 +127,15 @@ describe('core/utils', () => { expect(serialized).not.toBe(key) }) + it('stops at a depth limit when the serializer is not idempotent', () => { + // this serializer wraps its input on every call, so it would recurse + // forever without the depth limit + const serializer = vi.fn((value: unknown) => ({ wrapped: value })) + + expect(() => serializeCacheKey(['todos'], serializer)).not.toThrow() + expect(serializer.mock.calls.length).toBeLessThan(600) + }) + it('does not cache failed serialization', () => { const serializer = vi.fn(() => { throw new Error('serialize failed') @@ -135,20 +152,27 @@ describe('core/utils', () => { }) }) - it('should return `false` for same-length objects with different keys', () => { - // Both objects have the same number of keys, but the key sets differ. - // `b` is missing on the second object and `c` is missing on the first, - // so they are not shallowly equal even though both differing values - // happen to be `undefined`. - const value = Object.create({ inherited: 1 }) - expect(shallowEqualObjects(value, value)).toBe(true) - }) - describe('shallowEqualObjects', () => { it('should return `true` for shallow equal objects', () => { expect(shallowEqualObjects({ a: 1 }, { a: 1 })).toBe(true) }) + it('should compare inherited enumerable properties', () => { + // the length check uses `Object.keys`, which reads own properties only, + // but the comparison loop is a `for...in`, which also reads the prototype + const proto = { inherited: 1 } + + expect( + shallowEqualObjects(Object.create(proto), Object.create(proto)), + ).toBe(true) + expect( + shallowEqualObjects( + Object.create(proto), + Object.create({ inherited: 2 }), + ), + ).toBe(false) + }) + it('should return `false` for non shallow equal objects', () => { expect(shallowEqualObjects({ a: 1 }, { a: 2 })).toBe(false) }) diff --git a/packages/query-core/src/mutation.ts b/packages/query-core/src/mutation.ts index 7e09a3069bc..c1c1b79e550 100644 --- a/packages/query-core/src/mutation.ts +++ b/packages/query-core/src/mutation.ts @@ -1,7 +1,6 @@ import { notifyManager } from './notifyManager' import { Removable } from './removable' import { createRetryer } from './retryer' -import { hashKey, serializeCacheKey } from './utils' import type { CacheKeyConfig, DefaultError, @@ -22,7 +21,6 @@ interface MutationConfig { client: QueryClient mutationId: number mutationCache: MutationCache - mutationHash?: string options: MutationOptions state?: MutationState } @@ -93,7 +91,6 @@ export class Mutation< > extends Removable { state: MutationState options!: MutationOptions - mutationHash?: string readonly mutationId: number #client: QueryClient @@ -115,18 +112,6 @@ export class Mutation< this.state = config.state || getDefaultState() this.setOptions(config.options) - const serializedMutationKey = config.options.mutationKey - ? serializeCacheKey( - config.options.mutationKey, - this.#mutationCache.config.valueSerializer, - ) - : undefined - this.mutationHash = - config.mutationHash ?? - (serializedMutationKey - ? (this.#mutationCache.config.hashFn?.(serializedMutationKey) ?? - hashKey(serializedMutationKey)) - : undefined) this.scheduleGc() } diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index 98860841276..4055c4d6944 100644 --- a/packages/query-core/src/mutationObserver.ts +++ b/packages/query-core/src/mutationObserver.ts @@ -1,7 +1,7 @@ import { getDefaultState } from './mutation' import { notifyManager } from './notifyManager' import { Subscribable } from './subscribable' -import { hashKey, serializeCacheKey, shallowEqualObjects } from './utils' +import { hashCacheKey, shallowEqualObjects } from './utils' import type { QueryClient } from './queryClient' import type { DefaultError, @@ -83,15 +83,13 @@ export class MutationObserver< }) } - const hashFn = this.#client.getMutationCache().config.hashFn ?? hashKey - const valueSerializer = - this.#client.getMutationCache().config.valueSerializer + const config = this.#client.getMutationCache().config if ( prevOptions?.mutationKey && defaultedOptions.mutationKey && - hashFn(serializeCacheKey(prevOptions.mutationKey, valueSerializer)) !== - hashFn(serializeCacheKey(defaultedOptions.mutationKey, valueSerializer)) + hashCacheKey(prevOptions.mutationKey, config) !== + hashCacheKey(defaultedOptions.mutationKey, config) ) { this.reset() } else if (this.#currentMutation?.state.status === 'pending') { diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 62543ebdf3c..e625ee7ee16 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -1,10 +1,9 @@ import { functionalUpdate, - hashKey, + hashCacheKey, noop, partialMatchKey, resolveStaleTime, - serializeCacheKey, skipToken, } from './utils' import { QueryCache } from './queryCache' @@ -15,7 +14,7 @@ import { notifyManager } from './notifyManager' import type { MutationFilters, QueryFilters, Updater } from './utils' import type { CacheKey, - CacheKeyConfig, + CacheKeyValueSerializer, CancelOptions, DefaultError, DefaultOptions, @@ -50,29 +49,24 @@ import type { QueryState } from './query' // TYPES interface QueryDefaults { - queryKey: QueryKey + key: QueryKey defaultOptions: OmitKeyof, 'queryKey'> } interface MutationDefaults { - mutationKey: MutationKey + key: MutationKey defaultOptions: MutationOptions } function mergeCacheKeyDefaults( - defaults: Iterable< - | { queryKey: QueryKey; defaultOptions: TOptions } - | { mutationKey: MutationKey; defaultOptions: TOptions } - >, + defaults: Iterable<{ key: CacheKey; defaultOptions: TOptions }>, key: CacheKey, - valueSerializer: CacheKeyConfig['valueSerializer'], + valueSerializer: CacheKeyValueSerializer | undefined, ): TOptions { const result = {} as TOptions for (const keyDefault of defaults) { - const defaultKey = - 'queryKey' in keyDefault ? keyDefault.queryKey : keyDefault.mutationKey - if (partialMatchKey(key, defaultKey, valueSerializer)) { + if (partialMatchKey(key, keyDefault.key, valueSerializer)) { Object.assign(result, keyDefault.defaultOptions) } } @@ -80,18 +74,6 @@ function mergeCacheKeyDefaults( return result } -function hashCacheKey( - cacheKey: TCacheKey, - config: Readonly>, -): string { - const serializedCacheKey = serializeCacheKey( - cacheKey, - config.valueSerializer, - ) as TCacheKey - - return config.hashFn?.(serializedCacheKey) ?? hashKey(serializedCacheKey) -} - // CLASS export class QueryClient { @@ -596,9 +578,8 @@ export class QueryClient { > >, ): void { - const queryHash = hashCacheKey(queryKey, this.#queryCache.config) - this.#queryDefaults.set(queryHash, { - queryKey, + this.#queryDefaults.set(hashCacheKey(queryKey, this.#queryCache.config), { + key: queryKey, defaultOptions: options, }) } @@ -625,11 +606,13 @@ export class QueryClient { 'mutationKey' >, ): void { - const queryHash = hashCacheKey(mutationKey, this.#mutationCache.config) - this.#mutationDefaults.set(queryHash, { - mutationKey, - defaultOptions: options, - }) + this.#mutationDefaults.set( + hashCacheKey(mutationKey, this.#mutationCache.config), + { + key: mutationKey, + defaultOptions: options, + }, + ) } getMutationDefaults( @@ -683,26 +666,18 @@ export class QueryClient { > } - const serializedCacheKey = serializeCacheKey( - options.queryKey, - this.#queryCache.config.valueSerializer, - ) as TQueryKey - const defaultedOptions = { ...this.#defaultOptions.queries, - ...mergeCacheKeyDefaults( - this.#queryDefaults.values(), - options.queryKey, - this.#queryCache.config.valueSerializer, - ), + ...this.getQueryDefaults(options.queryKey), ...options, _defaulted: true, } - defaultedOptions.queryHash ??= - this.#queryCache.config.hashFn?.(serializedCacheKey) ?? - defaultedOptions.queryKeyHashFn?.(serializedCacheKey) ?? - hashKey(serializedCacheKey) + defaultedOptions.queryHash ??= hashCacheKey( + defaultedOptions.queryKey, + this.#queryCache.config, + defaultedOptions.queryKeyHashFn, + ) // dependent default values if (defaultedOptions.refetchOnReconnect === undefined) { @@ -739,12 +714,7 @@ export class QueryClient { return { ...this.#defaultOptions.mutations, - ...(options?.mutationKey && - mergeCacheKeyDefaults( - this.#mutationDefaults.values(), - options.mutationKey, - this.#mutationCache.config.valueSerializer, - )), + ...(options?.mutationKey && this.getMutationDefaults(options.mutationKey)), ...options, _defaulted: true, } as DefaultedMutationOptions diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index ea1018d48f2..edf7b2410d4 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,6 +1,8 @@ import { timeoutManager } from './timeoutManager' import type { CacheKey, + CacheKeyConfig, + CacheKeyHashFunction, CacheKeyValueSerializer, DefaultError, FetchStatus, @@ -157,14 +159,12 @@ export function matchQuery( if (queryKey) { const config = query.cacheKeyConfig - const serializedKey = serializeCacheKey(queryKey, config.valueSerializer) if (exact) { - const queryHash = - config.hashFn?.(serializedKey) ?? - query.options.queryKeyHashFn?.(serializedKey) ?? - hashKey(serializedKey) - if (query.queryHash !== queryHash) { + if ( + query.queryHash !== + hashCacheKey(queryKey, config, query.options.queryKeyHashFn) + ) { return false } } else if ( @@ -210,12 +210,12 @@ export function matchMutation( } const config = mutation.cacheKeyConfig - const serializedKey = serializeCacheKey(mutationKey, config.valueSerializer) if (exact) { - const mutationHash = - config.hashFn?.(serializedKey) ?? hashKey(serializedKey) - if (mutation.mutationHash !== mutationHash) { + if ( + hashCacheKey(mutation.options.mutationKey, config) !== + hashCacheKey(mutationKey, config) + ) { return false } } else if ( @@ -257,6 +257,23 @@ export function hashKey(cacheKey: CacheKey): string { ) } +/** + * Serializes a cache key, then hashes it into the cache identity string. + */ +export function hashCacheKey( + key: CacheKey, + config: CacheKeyConfig, + legacyHashFn?: CacheKeyHashFunction, +): string { + const serializedKey = serializeCacheKey(key, config.valueSerializer) + + return ( + config.hashFn?.(serializedKey) ?? + legacyHashFn?.(serializedKey) ?? + hashKey(serializedKey) + ) +} + const cacheKeySerializationCache = new WeakMap< CacheKeyValueSerializer, WeakMap @@ -265,48 +282,29 @@ const cacheKeySerializationCache = new WeakMap< function serializeCacheKeyValue( value: unknown, serializer: CacheKeyValueSerializer, + depth = 0, ): unknown { - if (isPlainArray(value)) { - let result = value - - for (let index = 0; index < value.length; index++) { - const item = value[index] - const serializedItem = serializeCacheKeyValue(item, serializer) - if (serializedItem !== item) { - if (result === value) { - result = value.slice() - } - result[index] = serializedItem - } - } + if (depth > 500) return value - return result + if (isPlainArray(value)) { + return value.map((item) => + serializeCacheKeyValue(item, serializer, depth + 1), + ) } if (isPlainObject(value)) { - let result = value - + const result: Record = {} for (const key of Object.keys(value)) { - const item = value[key] - const serializedItem = serializeCacheKeyValue(item, serializer) - if (serializedItem !== item) { - if (result === value) { - result = { ...value } - } - result[key] = serializedItem - } + result[key] = serializeCacheKeyValue(value[key], serializer, depth + 1) } - return result } const serializedValue = serializer(value) - if (serializedValue === value) { - return value - } - return Array.isArray(serializedValue) || isPlainObject(serializedValue) - ? serializeCacheKeyValue(serializedValue, serializer) + return serializedValue !== value && + (isPlainArray(serializedValue) || isPlainObject(serializedValue)) + ? serializeCacheKeyValue(serializedValue, serializer, depth + 1) : serializedValue } diff --git a/packages/query-persist-client-core/src/createPersister.ts b/packages/query-persist-client-core/src/createPersister.ts index ff1232ed1dc..86fd3bfddc1 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -2,7 +2,6 @@ import { matchQuery, notifyManager, partialMatchKey, - serializeCacheKey, } from '@tanstack/query-core' import type { Query, @@ -123,21 +122,13 @@ export function experimental_createQueryPersister({ return true } - function getCacheKeyFilter( + function getFilterQueryHash( queryClient: QueryClient, queryKey: QueryKey | undefined, - ) { - if (!queryKey) { - return {} - } - - return { - filterKey: serializeCacheKey( - queryKey, - queryClient.getQueryCache().config.valueSerializer, - ), - queryHash: queryClient.defaultQueryOptions({ queryKey }).queryHash, - } + ): string | undefined { + return queryKey + ? queryClient.defaultQueryOptions({ queryKey }).queryHash + : undefined } async function retrieveQuery( @@ -293,7 +284,8 @@ export function experimental_createQueryPersister({ queryFilters: Pick = {}, ): Promise { const { exact, queryKey } = queryFilters - const { filterKey, queryHash } = getCacheKeyFilter(queryClient, queryKey) + const queryHash = getFilterQueryHash(queryClient, queryKey) + const valueSerializer = queryClient.getQueryCache().config.valueSerializer if (storage?.entries) { const storageKeyPrefix = `${prefix}-` @@ -319,11 +311,9 @@ export function experimental_createQueryPersister({ } } else if ( !partialMatchKey( - serializeCacheKey( - persistedQuery.queryKey, - queryClient.getQueryCache().config.valueSerializer, - ), - filterKey!, + persistedQuery.queryKey, + queryKey, + valueSerializer, ) ) { continue @@ -351,7 +341,8 @@ export function experimental_createQueryPersister({ queryFilters: Pick = {}, ): Promise { const { exact, queryKey } = queryFilters - const { filterKey, queryHash } = getCacheKeyFilter(queryClient, queryKey) + const queryHash = getFilterQueryHash(queryClient, queryKey) + const valueSerializer = queryClient.getQueryCache().config.valueSerializer if (storage?.entries) { const entries = await storage.entries() @@ -376,13 +367,7 @@ export function experimental_createQueryPersister({ continue } } else if ( - !partialMatchKey( - serializeCacheKey( - persistedQuery.queryKey, - queryClient.getQueryCache().config.valueSerializer, - ), - filterKey!, - ) + !partialMatchKey(persistedQuery.queryKey, queryKey, valueSerializer) ) { continue } From c973e0a8a8af69530af83d6ace38a55f83380626 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:22:30 +0000 Subject: [PATCH 10/10] ci: apply automated fixes --- packages/query-core/src/queryClient.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index e625ee7ee16..fe49a9081b0 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -714,7 +714,8 @@ export class QueryClient { return { ...this.#defaultOptions.mutations, - ...(options?.mutationKey && this.getMutationDefaults(options.mutationKey)), + ...(options?.mutationKey && + this.getMutationDefaults(options.mutationKey)), ...options, _defaulted: true, } as DefaultedMutationOptions