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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/cache-key-serializers.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions docs/framework/react/guides/query-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 7 additions & 7 deletions docs/framework/react/plugins/createPersister.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Invoking `experimental_createQueryPersister` returns additional utilities in add

### `persistQueryByKey(queryKey: QueryKey, queryClient: QueryClient): Promise<void>`

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
Expand All @@ -101,19 +101,19 @@ useMutation({

### `retrieveQuery<T>(queryHash: string): Promise<T | undefined>`

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<void>`

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<void>`

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:
Expand All @@ -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<void>`
### `removeQueries(queryClient: QueryClient, filters?): Promise<void>`

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.
Expand All @@ -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
Expand Down
4 changes: 0 additions & 4 deletions docs/framework/react/reference/useQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ const {
meta,
notifyOnChangeProps,
placeholderData,
queryKeyHashFn,
refetchInterval,
refetchIntervalInBackground,
refetchOnMount,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 0 additions & 4 deletions docs/framework/solid/reference/useQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ const {
initialData,
initialDataUpdatedAt,
meta,
queryKeyHashFn,
refetchInterval,
refetchIntervalInBackground,
refetchOnMount,
Expand Down Expand Up @@ -242,9 +241,6 @@ function App() {
- ##### `meta: Record<string, unknown>`
- 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
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/MutationCache.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand All @@ -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> | unknown`
- Optional
- This function will be called if some mutation encounters an error.
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/QueryCache.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/QueryClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion packages/lit-query/src/tests/queries-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 21 additions & 7 deletions packages/preact-query/src/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, <Page />)
renderWithClient(customQueryClient, <Page />)

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, <Page />)
renderWithClient(customQueryClient, <Page />)

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 () => {
Expand Down
31 changes: 31 additions & 0 deletions packages/query-core/src/__tests__/hydration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ describe('InfiniteQueryObserver', () => {
throwOnError: true,
refetchOnReconnect: false,
queryHash: key.join(''),
_defaulted: true,
behavior: undefined,
}

Expand Down
50 changes: 50 additions & 0 deletions packages/query-core/src/__tests__/mutationCache.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading