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 7451738dae1..8b287ac0c3e 100644 --- a/docs/framework/react/guides/query-keys.md +++ b/docs/framework/react/guides/query-keys.md @@ -74,6 +74,30 @@ 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 }) +``` + +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 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..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,10 +123,10 @@ 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(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. @@ -138,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/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..dbc7ddf7cf2 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. 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 6a341205ccf..5f45b567de3 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. 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/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/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 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..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__/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..9001604d9e1 100644 --- a/packages/query-core/src/__tests__/mutationCache.test.tsx +++ b/packages/query-core/src/__tests__/mutationCache.test.tsx @@ -348,6 +348,56 @@ describe('mutationCache', () => { ).toEqual([mutation2]) expect(testCache.findAll({ mutationKey: ['unknown'] })).toEqual([]) }) + + it('should use the shared cache serializer 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() + + // `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(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(4) + expect(hashFn).toHaveBeenCalledTimes(6) + + 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..743f9d3af46 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 shared cache serializer 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(2) + + valueSerializer.mockClear() + hashFn.mockClear() + + expect( + testCache.findAll({ queryKey: ['number', date], exact: true }), + ).toEqual([numberQuery]) + expect(valueSerializer).toHaveBeenCalledTimes(2) + expect(hashFn).toHaveBeenCalledTimes(3) + + 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..4df1a4b2d63 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' @@ -8,17 +10,63 @@ import type { Query, QueryState } from '../query' import type { DataTag, DefaultError, + DefaultedMutationOptions, DefaultedQueryObserverOptions, EnsureQueryDataOptions, 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 allow query-level hash functions', () => { + const queryClient = new QueryClient() + + queryClient.fetchQuery({ + queryKey: ['key'], + queryFn: () => 'data', + queryKeyHashFn: () => 'hash', + }) + + expectTypeOf(queryClient).toEqualTypeOf() + }) +}) + describe('getQueryData', () => { it('should be typed if key is tagged', () => { const key = ['key'] as DataTag, number> @@ -482,7 +530,7 @@ describe('fully typed usage', () => { const mutationOptions2 = queryClient.defaultMutationOptions(mutationOptions) expectTypeOf(mutationOptions2).toEqualTypeOf< - MutationOptions + DefaultedMutationOptions> >() queryClient.setMutationDefaults(mutationKey, { @@ -637,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 f68670104a2..0fff6c945b6 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, @@ -10,14 +12,15 @@ import { hydrate, noop, onlineManager, + serializeCacheKey, skipToken, } from '..' import { mockOnlineManagerIsOnline } from './utils' import type { InfiniteData, Query, - QueryCache, QueryFunction, + QueryFunctionContext, QueryObserverOptions, } from '..' @@ -75,6 +78,503 @@ 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], + }) + + const query = testClient.getQueryCache().getAll()[0] + const mutation = testClient.getMutationCache().getAll()[0] + expect(query?.queryKey).toEqual(['date', date]) + expect(mutation?.options.mutationKey).toEqual(['date', date]) + }) + + 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 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)] as const + const queryFn = vi.fn((context: QueryFunctionContext) => + context.queryKey[1].getTime(), + ) + + const defaultedOptions = testClient.defaultQueryOptions({ queryKey: key }) + expect(defaultedOptions.queryKey).toBe(key) + + 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) + }) + + 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 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.queryHash).toBe('["counts",[["total","1"]]]') + }) + + 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) => + value instanceof Date ? value.toISOString() : value, + }), + }) + + const cacheKey = serializeCacheKey( + key, + testClient.getQueryCache().config.valueSerializer, + ) + + 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', () => { + 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 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 + 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([[keyA, '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(), + }) + + // 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', + new Map([['a', 1]]), + ]) + + expect( + testClient.getMutationCache().findAll({ + mutationKey: ['maps', new Map([['a', 1]])], + }).length, + ).toBe(1) + expect(valueSerializer).toHaveBeenCalled() + 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([[keyA, 'a']]) + expect(testClient.getQueriesData({ queryKey: ['maps'] })).toEqual([ + [keyA, 'a'], + [keyB, 'b'], + ]) + }) + + it('should keep original 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', new Map([['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([[key, 'data']]) + expect(valueSerializer).toHaveBeenCalledTimes(4) + expect(hashFn).toHaveBeenCalledTimes(2) + + valueSerializer.mockClear() + hashFn.mockClear() + + expect(testClient.getQueriesData({ queryKey: ['maps'] })).toEqual([ + [key, 'data'], + [otherKey, '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 +713,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..2f4923d7927 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -1,20 +1,21 @@ 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, + serializeCacheKey, shallowEqualObjects, shouldThrowError, skipToken, @@ -23,25 +24,131 @@ 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') + 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 result = hashQueryKeyByOptions(key, { - queryKeyHashFn: customHashFn, - }) + 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('serializes nested objects and arrays recursively', () => { + const serializer = vi.fn((value: unknown) => + typeof value === 'number' ? String(value) : value, + ) + const untouched = { status: 'active' } + 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: { status: 'active' }, + }, + [{ offset: '3' }, { status: 'active' }], + ]) + expect(serialized).not.toBe(key) + expect(key).toEqual([ + 'todos', + { page: 1, filters: { limit: 2 }, untouched: { status: 'active' } }, + [{ offset: 3 }, { status: 'active' }], + ]) + }) + + it('serializes every leaf value of the key', () => { + const serializer = vi.fn((value: unknown) => value) + const key = ['todos', { status: 'active', tags: ['one', 'two'] }] + + const serialized = serializeCacheKey(key, serializer) + + expect(serialized).toEqual(key) + expect(serializer.mock.calls.flat()).toEqual([ + 'todos', + 'active', + 'one', + 'two', + ]) + }) + + 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(customHashFn).toHaveBeenCalledWith(key) - expect(result).toEqual('custom-hash') + expect(serialized).toEqual(['dates', { timestamp: 0 }]) + expect(serialized).not.toBe(key) }) - 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) + 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(result).toEqual(defaultResult) + 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') + }) + const key = ['broken', new Date(0)] + + expect(() => serializeCacheKey(key, serializer)).toThrow( + 'serialize failed', + ) + expect(() => serializeCacheKey(key, serializer)).toThrow( + 'serialize failed', + ) + expect(serializer).toHaveBeenCalledTimes(2) }) }) @@ -50,6 +157,22 @@ describe('core/utils', () => { 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) }) @@ -126,6 +249,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 +676,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..c1c1b79e550 100644 --- a/packages/query-core/src/mutation.ts +++ b/packages/query-core/src/mutation.ts @@ -2,8 +2,10 @@ import { notifyManager } from './notifyManager' import { Removable } from './removable' import { createRetryer } from './retryer' import type { + CacheKeyConfig, DefaultError, MutationFunctionContext, + MutationKey, MutationMeta, MutationOptions, MutationStatus, @@ -113,6 +115,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..a2d16ed0890 100644 --- a/packages/query-core/src/mutationCache.ts +++ b/packages/query-core/src/mutationCache.ts @@ -4,8 +4,10 @@ import { matchMutation, 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() @@ -217,7 +219,12 @@ export class MutationCache extends Subscribable { } 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((mutation) => matchMutation(filters, mutation)) } notify(event: MutationCacheNotifyEvent) { diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index 8164e2ad024..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, shallowEqualObjects } from './utils' +import { hashCacheKey, shallowEqualObjects } from './utils' import type { QueryClient } from './queryClient' import type { DefaultError, @@ -73,7 +73,8 @@ export class MutationObserver< const prevOptions = this.options as | MutationObserverOptions | 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', @@ -82,14 +83,17 @@ export class MutationObserver< }) } + const config = this.#client.getMutationCache().config + if ( prevOptions?.mutationKey && - this.options.mutationKey && - hashKey(prevOptions.mutationKey) !== hashKey(this.options.mutationKey) + defaultedOptions.mutationKey && + hashCacheKey(prevOptions.mutationKey, config) !== + hashCacheKey(defaultedOptions.mutationKey, config) ) { 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 245ec283781..6f788d3cede 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, @@ -178,10 +179,10 @@ 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.queryKey = config.queryKey this.queryHash = config.queryHash this.#initialState = getDefaultState(this.options) @@ -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..7b39830dab1 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 { matchQuery } 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), }) @@ -192,9 +193,11 @@ export class QueryCache extends Subscribable { 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((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 be8a01216a3..fe49a9081b0 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -1,7 +1,6 @@ import { functionalUpdate, - hashKey, - hashQueryKeyByOptions, + hashCacheKey, noop, partialMatchKey, resolveStaleTime, @@ -12,10 +11,14 @@ 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, + CacheKeyValueSerializer, CancelOptions, DefaultError, DefaultOptions, + DefaultedMutationOptions, DefaultedQueryObserverOptions, EnsureInfiniteQueryDataOptions, EnsureQueryDataOptions, @@ -42,20 +45,35 @@ import type { SetDataOptions, } from './types' import type { QueryState } from './query' -import type { MutationFilters, QueryFilters, Updater } from './utils' // TYPES interface QueryDefaults { - queryKey: QueryKey + key: QueryKey defaultOptions: OmitKeyof, 'queryKey'> } interface MutationDefaults { - mutationKey: MutationKey + key: MutationKey defaultOptions: MutationOptions } +function mergeCacheKeyDefaults( + defaults: Iterable<{ key: CacheKey; defaultOptions: TOptions }>, + key: CacheKey, + valueSerializer: CacheKeyValueSerializer | undefined, +): TOptions { + const result = {} as TOptions + + for (const keyDefault of defaults) { + if (partialMatchKey(key, keyDefault.key, valueSerializer)) { + Object.assign(result, keyDefault.defaultOptions) + } + } + + return result +} + // CLASS export class QueryClient { @@ -560,8 +578,8 @@ export class QueryClient { > >, ): void { - this.#queryDefaults.set(hashKey(queryKey), { - queryKey, + this.#queryDefaults.set(hashCacheKey(queryKey, this.#queryCache.config), { + key: queryKey, defaultOptions: options, }) } @@ -569,19 +587,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 + return mergeCacheKeyDefaults( + this.#queryDefaults.values(), + queryKey, + this.#queryCache.config.valueSerializer, + ) } setMutationDefaults< @@ -596,29 +606,23 @@ export class QueryClient { 'mutationKey' >, ): void { - this.#mutationDefaults.set(hashKey(mutationKey), { - mutationKey, - defaultOptions: options, - }) + this.#mutationDefaults.set( + hashCacheKey(mutationKey, this.#mutationCache.config), + { + key: mutationKey, + defaultOptions: options, + }, + ) } 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 + return mergeCacheKeyDefaults( + this.#mutationDefaults.values(), + mutationKey, + this.#mutationCache.config.valueSerializer, + ) } defaultQueryOptions< @@ -669,12 +673,11 @@ export class QueryClient { _defaulted: true, } - if (!defaultedOptions.queryHash) { - defaultedOptions.queryHash = hashQueryKeyByOptions( - defaultedOptions.queryKey, - defaultedOptions, - ) - } + defaultedOptions.queryHash ??= hashCacheKey( + defaultedOptions.queryKey, + this.#queryCache.config, + defaultedOptions.queryKeyHashFn, + ) // dependent default values if (defaultedOptions.refetchOnReconnect === undefined) { @@ -704,17 +707,18 @@ export class QueryClient { defaultMutationOptions>( options?: T, - ): T { + ): DefaultedMutationOptions { if (options?._defaulted) { - return options + return options as DefaultedMutationOptions } + return { ...this.#defaultOptions.mutations, ...(options?.mutationKey && this.getMutationDefaults(options.mutationKey)), ...options, _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 e0220c5978d..54237c27e92 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 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 +} + export type GetPreviousPageParamFunction = ( firstPage: TQueryFnData, allPages: Array, @@ -254,8 +276,11 @@ export interface QueryOptions< queryFn?: QueryFunction | SkipToken persister?: QueryPersister, TPageParam> queryHash?: string - queryKey?: TQueryKey + /** + * @deprecated Configure `hashFn` on `QueryCache` instead. + */ queryKeyHashFn?: QueryKeyHashFunction + queryKey?: TQueryKey initialData?: TData | InitialDataFunction initialDataUpdatedAt?: number | (() => number | undefined) behavior?: QueryBehavior @@ -452,7 +477,9 @@ export type DefaultedQueryObserverOptions< > = WithRequired< QueryObserverOptions, 'throwOnError' | 'refetchOnReconnect' | 'queryHash' -> +> & { + _defaulted: true +} export interface InfiniteQueryObserverOptions< TQueryFnData = unknown, @@ -487,7 +514,9 @@ export type DefaultedInfiniteQueryObserverOptions< TPageParam >, 'throwOnError' | 'refetchOnReconnect' | 'queryHash' -> +> & { + _defaulted: true +} export interface QueryExecuteOptions< TQueryFnData = unknown, @@ -1075,6 +1104,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 = { @@ -1140,6 +1174,12 @@ export interface MutationOptions< scope?: MutationScope } +export type DefaultedMutationOptions< + TOptions extends MutationOptions, +> = TOptions & { + _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 f442ab86fdc..edf7b2410d4 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,5 +1,9 @@ import { timeoutManager } from './timeoutManager' import type { + CacheKey, + CacheKeyConfig, + CacheKeyHashFunction, + CacheKeyValueSerializer, DefaultError, FetchStatus, MutationKey, @@ -154,11 +158,18 @@ export function matchQuery( } = filters if (queryKey) { + const config = query.cacheKeyConfig + if (exact) { - if (query.queryHash !== hashQueryKeyByOptions(queryKey, query.options)) { + if ( + query.queryHash !== + hashCacheKey(queryKey, config, query.options.queryKeyHashFn) + ) { return false } - } else if (!partialMatchKey(query.queryKey, queryKey)) { + } else if ( + !partialMatchKey(query.queryKey, queryKey, config.valueSerializer) + ) { return false } } @@ -197,11 +208,23 @@ export function matchMutation( if (!mutation.options.mutationKey) { return false } + + const config = mutation.cacheKeyConfig + if (exact) { - if (hashKey(mutation.options.mutationKey) !== hashKey(mutationKey)) { + if ( + hashCacheKey(mutation.options.mutationKey, config) !== + hashCacheKey(mutationKey, config) + ) { return false } - } else if (!partialMatchKey(mutation.options.mutationKey, mutationKey)) { + } else if ( + !partialMatchKey( + mutation.options.mutationKey, + mutationKey, + config.valueSerializer, + ) + ) { return false } } @@ -217,20 +240,12 @@ export function matchMutation( return true } -export function hashQueryKeyByOptions( - queryKey: TQueryKey, - options?: Pick, 'queryKeyHashFn'>, -): string { - const hashFn = options?.queryKeyHashFn || hashKey - return hashFn(queryKey) -} - /** - * 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 +257,101 @@ export function hashKey(queryKey: QueryKey | MutationKey): 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 +>() + +function serializeCacheKeyValue( + value: unknown, + serializer: CacheKeyValueSerializer, + depth = 0, +): unknown { + if (depth > 500) return value + + if (isPlainArray(value)) { + return value.map((item) => + serializeCacheKeyValue(item, serializer, depth + 1), + ) + } + + if (isPlainObject(value)) { + const result: Record = {} + for (const key of Object.keys(value)) { + result[key] = serializeCacheKeyValue(value[key], serializer, depth + 1) + } + return result + } + + const serializedValue = serializer(value) + + return serializedValue !== value && + (isPlainArray(serializedValue) || isPlainObject(serializedValue)) + ? serializeCacheKeyValue(serializedValue, serializer, depth + 1) + : serializedValue +} + +export function serializeCacheKey( + key: CacheKey, + serializer: CacheKeyValueSerializer | undefined, +): CacheKey { + if (!serializer) { + return key + } + + let serializerCache = cacheKeySerializationCache.get(serializer) + if (!serializerCache) { + serializerCache = new WeakMap() + cacheKeySerializationCache.set(serializer, serializerCache) + } + + const cachedKey = serializerCache.get(key) + if (cachedKey) { + return cachedKey + } + + const serializedKey = serializeCacheKeyValue(key, serializer) as CacheKey + serializerCache.set(key, serializedKey) + return 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 +363,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-persist-client-core/src/__tests__/createPersister.test.ts b/packages/query-persist-client-core/src/__tests__/createPersister.test.ts index ca0a8aeddbf..40fa963373e 100644 --- a/packages/query-persist-client-core/src/__tests__/createPersister.test.ts +++ b/packages/query-persist-client-core/src/__tests__/createPersister.test.ts @@ -1,11 +1,14 @@ 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, } 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 () => { @@ -592,6 +634,98 @@ 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 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'], { @@ -737,6 +871,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 +937,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 +951,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 +965,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 +979,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 +993,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 +1010,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 +1019,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..86fd3bfddc1 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -1,5 +1,4 @@ import { - hashKey, matchQuery, notifyManager, partialMatchKey, @@ -16,6 +15,7 @@ import type { export interface PersistedQuery { buster: string queryHash: string + /** The original public query key. */ queryKey: QueryKey state: QueryState } @@ -122,6 +122,15 @@ export function experimental_createQueryPersister({ return true } + function getFilterQueryHash( + queryClient: QueryClient, + queryKey: QueryKey | undefined, + ): string | undefined { + return queryKey + ? queryClient.defaultQueryOptions({ queryKey }).queryHash + : undefined + } + async function retrieveQuery( queryHash: string, afterRestoreMacroTask?: (persistedQuery: PersistedQuery) => void, @@ -272,9 +281,11 @@ export function experimental_createQueryPersister({ async function restoreQueries( queryClient: QueryClient, - filters: Pick = {}, + queryFilters: Pick = {}, ): Promise { - const { exact, queryKey } = filters + const { exact, queryKey } = queryFilters + const queryHash = getFilterQueryHash(queryClient, queryKey) + const valueSerializer = queryClient.getQueryCache().config.valueSerializer if (storage?.entries) { const storageKeyPrefix = `${prefix}-` @@ -295,10 +306,16 @@ export function experimental_createQueryPersister({ if (queryKey) { if (exact) { - if (persistedQuery.queryHash !== hashKey(queryKey)) { + if (persistedQuery.queryHash !== queryHash) { continue } - } else if (!partialMatchKey(persistedQuery.queryKey, queryKey)) { + } else if ( + !partialMatchKey( + persistedQuery.queryKey, + queryKey, + valueSerializer, + ) + ) { continue } } @@ -320,9 +337,12 @@ export function experimental_createQueryPersister({ } async function removeQueries( - filters: Pick = {}, + queryClient: QueryClient, + queryFilters: Pick = {}, ): Promise { - const { exact, queryKey } = filters + const { exact, queryKey } = queryFilters + const queryHash = getFilterQueryHash(queryClient, queryKey) + const valueSerializer = queryClient.getQueryCache().config.valueSerializer if (storage?.entries) { const entries = await storage.entries() @@ -343,10 +363,12 @@ export function experimental_createQueryPersister({ } if (exact) { - if (persistedQuery.queryHash !== hashKey(queryKey)) { + if (persistedQuery.queryHash !== queryHash) { continue } - } else if (!partialMatchKey(persistedQuery.queryKey, queryKey)) { + } else if ( + !partialMatchKey(persistedQuery.queryKey, queryKey, valueSerializer) + ) { 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..7a9cbeb1ded 100644 --- a/packages/vue-query/src/__tests__/queryClient.test.ts +++ b/packages/vue-query/src/__tests__/queryClient.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ref, unref } from 'vue-demi' -import { QueryClient as QueryClientOrigin } from '@tanstack/query-core' +import { + QueryCache, + QueryClient as QueryClientOrigin, +} from '@tanstack/query-core' import { QueryClient } from '../queryClient' import { infiniteQueryOptions } from '../infiniteQueryOptions' import { queryOptions } from '../queryOptions' @@ -44,6 +47,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()