From c33c2a39fd26058bf65693b00b42b1b216bff455 Mon Sep 17 00:00:00 2001 From: Vivien Gontier Date: Tue, 6 Oct 2026 09:56:53 +0200 Subject: [PATCH 1/4] fix(query-persist-client-core): handle nullish persisted query state --- .changeset/proud-donuts-shave.md | 5 ++ .../src/__tests__/createPersister.test.ts | 65 +++++++++++++++++++ .../src/createPersister.ts | 9 ++- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 .changeset/proud-donuts-shave.md diff --git a/.changeset/proud-donuts-shave.md b/.changeset/proud-donuts-shave.md new file mode 100644 index 00000000000..e4a4a62dc88 --- /dev/null +++ b/.changeset/proud-donuts-shave.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-persist-client-core": patch +--- + +Remove persisted entries with missing or null state without interrupting restoreQueries or persisterGc. 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 e6d14616826..3442ee79066 100644 --- a/packages/query-persist-client-core/src/__tests__/createPersister.test.ts +++ b/packages/query-persist-client-core/src/__tests__/createPersister.test.ts @@ -528,6 +528,22 @@ describe('createPersister', () => { }) describe('retrieveQuery', () => { + it.each(['null', '{}', '{"state":null}'])( + 'should remove malformed entry %s without restoring it', + async (value) => { + const storage = getFreshStorage() + const { persister, queryHash, storageKey } = setupPersister(['foo'], { + storage, + }) + + await storage.setItem(storageKey, value) + + const restoredData = await persister.retrieveQuery(queryHash) + expect(restoredData).toBeUndefined() + expect(await storage.getItem(storageKey)).toBeUndefined() + }, + ) + it('should return the persisted data when called without a restore callback', async () => { const storage = getFreshStorage() const { persister, client, queryHash, queryKey } = setupPersister( @@ -591,6 +607,31 @@ describe('createPersister', () => { await persister.persisterGc() expect(await storage.entries()).toHaveLength(1) }) + + it.each(['null', '{}', '{"state":null}'])( + 'should remove malformed entry %s and continue garbage collection', + async (value) => { + const storage = getFreshStorage() + const { persister, client, queryKey, storageKey } = setupPersister( + ['foo'], + { storage }, + ) + const malformedKey = `${PERSISTER_KEY_PREFIX}-["malformed"]` + const expiredKey = `${PERSISTER_KEY_PREFIX}-["expired"]` + + await storage.setItem(malformedKey, value) + client.setQueryData(['expired'], 'expired', { updatedAt: 1 }) + await persister.persistQueryByKey(['expired'], client) + client.setQueryData(queryKey, null) + await persister.persistQueryByKey(queryKey, client) + const persistedData = await storage.getItem(storageKey) + + await persister.persisterGc() + expect(await storage.getItem(malformedKey)).toBeUndefined() + expect(await storage.getItem(expiredKey)).toBeUndefined() + expect(await storage.entries()).toEqual([[storageKey, persistedData]]) + }, + ) }) describe('restoreQueries', () => { @@ -728,6 +769,30 @@ describe('createPersister', () => { expect(await storage.entries()).toHaveLength(0) expect(client.getQueryCache().getAll()).toHaveLength(0) }) + + it.each(['null', '{}', '{"state":null}'])( + 'should remove malformed entry %s and restore the next query', + async (value) => { + const storage = getFreshStorage() + const { persister, client, queryKey, storageKey } = setupPersister( + ['foo'], + { storage }, + ) + const malformedKey = `${PERSISTER_KEY_PREFIX}-["malformed"]` + + await storage.setItem(malformedKey, value) + client.setQueryData(queryKey, null) + await persister.persistQueryByKey(queryKey, client) + const persistedData = await storage.getItem(storageKey) + client.clear() + + await persister.restoreQueries(client) + expect(await storage.getItem(malformedKey)).toBeUndefined() + expect(await storage.entries()).toEqual([[storageKey, persistedData]]) + expect(client.getQueryCache().getAll()).toHaveLength(1) + expect(client.getQueryData(queryKey)).toBeNull() + }, + ) }) describe('removeQueries', () => { diff --git a/packages/query-persist-client-core/src/createPersister.ts b/packages/query-persist-client-core/src/createPersister.ts index 8a89dc351e2..b4462c71304 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -154,8 +154,13 @@ export function experimental_createQueryPersister({ * @returns `true` if it has no `dataUpdatedAt`, is older than `maxAge`, or has a different * `buster`. */ - function isExpiredOrBusted(persistedQuery: PersistedQuery) { - if (persistedQuery.state.dataUpdatedAt) { + function isExpiredOrBusted( + persistedQuery: + | { buster?: string; state?: Partial | null } + | null + | undefined, + ) { + if (persistedQuery?.state?.dataUpdatedAt) { const queryAge = Date.now() - persistedQuery.state.dataUpdatedAt const expired = queryAge > maxAge const busted = persistedQuery.buster !== buster From 846731dff3606ec31efe2c961b99f8beeabdefe0 Mon Sep 17 00:00:00 2001 From: Vivien Gontier Date: Sat, 10 Oct 2026 13:19:40 +0200 Subject: [PATCH 2/4] fix(query-persist-client-core): validate deserialized queries --- .changeset/proud-donuts-shave.md | 2 +- .../src/__tests__/createPersister.test.ts | 30 ++++++++++++++++ .../src/createPersister.ts | 36 +++++++++++++++---- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/.changeset/proud-donuts-shave.md b/.changeset/proud-donuts-shave.md index e4a4a62dc88..300ddb39f9c 100644 --- a/.changeset/proud-donuts-shave.md +++ b/.changeset/proud-donuts-shave.md @@ -2,4 +2,4 @@ "@tanstack/query-persist-client-core": patch --- -Remove persisted entries with missing or null state without interrupting restoreQueries or persisterGc. +Remove malformed persisted entries during retrieval, restoration, garbage collection, and filtered removal without interrupting subsequent queries. 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 3442ee79066..6b6b54e3928 100644 --- a/packages/query-persist-client-core/src/__tests__/createPersister.test.ts +++ b/packages/query-persist-client-core/src/__tests__/createPersister.test.ts @@ -796,6 +796,36 @@ describe('createPersister', () => { }) describe('removeQueries', () => { + describe.each([false, true])('with exact: %s', (exact) => { + it.each(['null', '{}', '{"state":null}', '42', '{"state":42}'])( + 'should remove malformed entry %s and continue removing matching queries', + async (value) => { + const storage = getFreshStorage() + const { persister, client, queryKey, storageKey } = setupPersister( + ['foo'], + { storage }, + ) + const malformedKey = `${PERSISTER_KEY_PREFIX}-["malformed"]` + const unrelatedKey = `${PERSISTER_KEY_PREFIX}-["bar"]` + + await storage.setItem(malformedKey, value) + client.setQueryData(queryKey, null) + await persister.persistQueryByKey(queryKey, client) + client.setQueryData(['bar'], null) + await persister.persistQueryByKey(['bar'], client) + const unrelatedData = await storage.getItem(unrelatedKey) + + await persister.removeQueries({ queryKey, exact }) + + expect(await storage.getItem(malformedKey)).toBeUndefined() + expect(await storage.getItem(storageKey)).toBeUndefined() + expect(await storage.entries()).toEqual([ + [unrelatedKey, unrelatedData], + ]) + }, + ) + }) + it('should remove restore queries from storage without filters', async () => { const storage = getFreshStorage() const { persister, client, queryKey } = setupPersister(['foo'], { diff --git a/packages/query-persist-client-core/src/createPersister.ts b/packages/query-persist-client-core/src/createPersister.ts index b4462c71304..6fa8e2d4dab 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -148,19 +148,29 @@ export function experimental_createQueryPersister({ refetchOnRestore = true, filters, }: StoragePersisterOptions) { + /** + * Checks that a deserialized value has a non-null object state. + * @param value - The deserialized value to check. + * @returns `true` if the value can be processed as a persisted query. + */ + function isPersistedQuery(value: unknown): value is PersistedQuery { + return ( + typeof value === 'object' && + value !== null && + 'state' in value && + typeof value.state === 'object' && + value.state !== null + ) + } + /** * Checks whether a persisted query should be discarded. * @param persistedQuery - The persisted query to check. * @returns `true` if it has no `dataUpdatedAt`, is older than `maxAge`, or has a different * `buster`. */ - function isExpiredOrBusted( - persistedQuery: - | { buster?: string; state?: Partial | null } - | null - | undefined, - ) { - if (persistedQuery?.state?.dataUpdatedAt) { + function isExpiredOrBusted(persistedQuery: PersistedQuery) { + if (persistedQuery.state.dataUpdatedAt) { const queryAge = Date.now() - persistedQuery.state.dataUpdatedAt const expired = queryAge > maxAge const busted = persistedQuery.buster !== buster @@ -195,6 +205,9 @@ export function experimental_createQueryPersister({ let persistedQuery: PersistedQuery try { persistedQuery = await deserialize(storedData) + if (!isPersistedQuery(persistedQuery)) { + throw new Error('Invalid persisted query') + } } catch { await storage.removeItem(storageKey) return @@ -339,6 +352,9 @@ export function experimental_createQueryPersister({ let persistedQuery: PersistedQuery try { persistedQuery = await deserialize(value) + if (!isPersistedQuery(persistedQuery)) { + throw new Error('Invalid persisted query') + } } catch { await storage.removeItem(key) continue @@ -377,6 +393,9 @@ export function experimental_createQueryPersister({ let persistedQuery: PersistedQuery try { persistedQuery = await deserialize(value) + if (!isPersistedQuery(persistedQuery)) { + throw new Error('Invalid persisted query') + } } catch { await storage.removeItem(key) continue @@ -437,6 +456,9 @@ export function experimental_createQueryPersister({ let persistedQuery: PersistedQuery try { persistedQuery = await deserialize(value) + if (!isPersistedQuery(persistedQuery)) { + throw new Error('Invalid persisted query') + } } catch { await storage.removeItem(key) continue From c1ac68536a7a17b2ff24867b25263ac25688fccc Mon Sep 17 00:00:00 2001 From: VGontier <57801283+VGontier-cmd@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:08:56 +0200 Subject: [PATCH 3/4] fix(query-persist-client-core): validate persisted query fields --- .changeset/proud-donuts-shave.md | 2 +- .../react/plugins/createPersister.md | 2 + docs/framework/vue/plugins/createPersister.md | 2 + .../__tests__/fine-grained-persister.test.tsx | 34 ++-- .../src/__tests__/createPersister.test.ts | 153 +++++++++++++----- .../src/createPersister.ts | 35 ++-- .../__tests__/fine-grained-persister.test.tsx | 34 ++-- 7 files changed, 163 insertions(+), 99 deletions(-) diff --git a/.changeset/proud-donuts-shave.md b/.changeset/proud-donuts-shave.md index 300ddb39f9c..52021478b91 100644 --- a/.changeset/proud-donuts-shave.md +++ b/.changeset/proud-donuts-shave.md @@ -2,4 +2,4 @@ "@tanstack/query-persist-client-core": patch --- -Remove malformed persisted entries during retrieval, restoration, garbage collection, and filtered removal without interrupting subsequent queries. +Validate persisted query keys, hashes, busters, data, and timestamps before retrieval, restoration, garbage collection, or filtered removal. Remove malformed entries without interrupting subsequent queries, while preserving valid `null` data. diff --git a/docs/framework/react/plugins/createPersister.md b/docs/framework/react/plugins/createPersister.md index e98b4377064..8fda8722f61 100644 --- a/docs/framework/react/plugins/createPersister.md +++ b/docs/framework/react/plugins/createPersister.md @@ -104,6 +104,8 @@ useMutation({ 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. +Persisted entries must have a string `buster` and `queryHash`, an array `queryKey`, and a `state` object with defined `data` and finite numeric `dataUpdatedAt` and `errorUpdatedAt` values. `null` is valid query data. Entries that do not meet these requirements are removed during retrieval, garbage collection, restoration, and filtered removal, without interrupting processing of other entries. + ### `persisterGc(): Promise` This function can be used to sporadically clean up storage from `expired`, `busted` or `malformed` entries. diff --git a/docs/framework/vue/plugins/createPersister.md b/docs/framework/vue/plugins/createPersister.md index abb9d4db33d..08f133c845a 100644 --- a/docs/framework/vue/plugins/createPersister.md +++ b/docs/framework/vue/plugins/createPersister.md @@ -101,6 +101,8 @@ useMutation({ 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. +Persisted entries must have a string `buster` and `queryHash`, an array `queryKey`, and a `state` object with defined `data` and finite numeric `dataUpdatedAt` and `errorUpdatedAt` values. `null` is valid query data. Entries that do not meet these requirements are removed during retrieval, garbage collection, restoration, and filtered removal, without interrupting processing of other entries. + ### `persisterGc(): Promise` This function can be used to sporadically clean up storage from `expired`, `busted` or `malformed` entries. diff --git a/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx b/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx index fb6538b26a3..db806a8eba5 100644 --- a/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx +++ b/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx @@ -26,7 +26,6 @@ describe('fine grained persister', () => { it('should restore query state from persister and not refetch', async () => { const key = queryKey() - const hash = hashKey(key) const spy = vi.fn(() => Promise.resolve('Works from queryFn')) const mapStorage = new Map() @@ -42,18 +41,12 @@ describe('fine grained persister', () => { }, } - await storage.setItem( - `${PERSISTER_KEY_PREFIX}-${hash}`, - JSON.stringify({ - buster: '', - queryHash: hash, - queryKey: key, - state: { - dataUpdatedAt: Date.now(), - data: 'Works from persister', - }, - }), + queryClient.setQueryData(key, 'Works from persister') + await experimental_createQueryPersister({ storage }).persistQueryByKey( + key, + queryClient, ) + queryClient.clear() function Test() { const [_ref, setRef] = useState() @@ -79,7 +72,6 @@ describe('fine grained persister', () => { it('should restore query state from persister and refetch', async () => { const key = queryKey() - const hash = hashKey(key) const spy = vi.fn(() => sleep(5).then(() => 'Works from queryFn')) const mapStorage = new Map() @@ -95,18 +87,12 @@ describe('fine grained persister', () => { }, } - await storage.setItem( - `${PERSISTER_KEY_PREFIX}-${hash}`, - JSON.stringify({ - buster: '', - queryHash: hash, - queryKey: key, - state: { - dataUpdatedAt: Date.now(), - data: 'Works from persister', - }, - }), + queryClient.setQueryData(key, 'Works from persister') + await experimental_createQueryPersister({ storage }).persistQueryByKey( + key, + queryClient, ) + queryClient.clear() function Test() { const [_ref, setRef] = useState() 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 6b6b54e3928..7d1db02728a 100644 --- a/packages/query-persist-client-core/src/__tests__/createPersister.test.ts +++ b/packages/query-persist-client-core/src/__tests__/createPersister.test.ts @@ -4,8 +4,15 @@ import { PERSISTER_KEY_PREFIX, experimental_createQueryPersister, } from '../createPersister' -import type { QueryFunctionContext, QueryKey } from '@tanstack/query-core' -import type { StoragePersisterOptions } from '../createPersister' +import type { + QueryFunctionContext, + QueryKey, + QueryState, +} from '@tanstack/query-core' +import type { + PersistedQuery, + StoragePersisterOptions, +} from '../createPersister' function getFreshStorage() { const storage = new Map() @@ -62,6 +69,48 @@ function setupPersister( } } +function createPersistedQuery(state: Partial = {}): PersistedQuery { + const { query, queryKey, queryHash } = setupPersister(['foo'], { + storage: undefined, + }) + + return { + buster: '', + queryKey, + queryHash, + state: { + ...query.state, + data: 'cached', + dataUpdatedAt: Date.now(), + ...state, + }, + } +} + +function getMalformedEntries() { + const persistedQuery = createPersistedQuery() + const values: Array = [ + null, + {}, + 42, + { state: null }, + { state: 42 }, + { ...persistedQuery, buster: 42 }, + { ...persistedQuery, queryHash: undefined }, + { ...persistedQuery, queryKey: 'foo' }, + ...[ + { data: undefined }, + { dataUpdatedAt: {} }, + { errorUpdatedAt: 'invalid' }, + ].map((state) => ({ + ...persistedQuery, + state: { ...persistedQuery.state, ...state }, + })), + ] + + return values.map((value) => JSON.stringify(value)) +} + describe('createPersister', () => { beforeAll(() => { vi.useFakeTimers() @@ -155,10 +204,7 @@ describe('createPersister', () => { await storage.setItem( storageKey, - JSON.stringify({ - buster: '', - state: { dataUpdatedAt: undefined }, - }), + JSON.stringify(createPersistedQuery({ dataUpdatedAt: undefined })), ) await persister.persisterFn(queryFn, context, query) @@ -179,10 +225,7 @@ describe('createPersister', () => { await storage.setItem( storageKey, - JSON.stringify({ - buster: '', - state: { dataUpdatedAt: Date.now() - 200 }, - }), + JSON.stringify(createPersistedQuery({ dataUpdatedAt: Date.now() - 200 })), ) await persister.persisterFn(queryFn, context, query) @@ -203,8 +246,8 @@ describe('createPersister', () => { await storage.setItem( storageKey, JSON.stringify({ + ...createPersistedQuery(), buster: 'bust', - state: { dataUpdatedAt: Date.now() }, }), ) @@ -227,10 +270,7 @@ describe('createPersister', () => { await storage.setItem( storageKey, - JSON.stringify({ - buster: '', - state: { dataUpdatedAt, data: '' }, - }), + JSON.stringify(createPersistedQuery({ dataUpdatedAt, data: '' })), ) await persister.persisterFn(queryFn, context, query) @@ -255,10 +295,7 @@ describe('createPersister', () => { await storage.setItem( storageKey, - JSON.stringify({ - buster: '', - state: { dataUpdatedAt: Date.now(), data: '' }, - }), + JSON.stringify(createPersistedQuery({ data: '' })), ) await persister.persisterFn(queryFn, context, query) @@ -282,10 +319,9 @@ describe('createPersister', () => { await storage.setItem( storageKey, - JSON.stringify({ - buster: '', - state: { dataUpdatedAt: Date.now() + 1000, data: '' }, - }), + JSON.stringify( + createPersistedQuery({ dataUpdatedAt: Date.now() + 1000, data: '' }), + ), ) await persister.persisterFn(queryFn, context, query) @@ -309,10 +345,7 @@ describe('createPersister', () => { await storage.setItem( storageKey, - JSON.stringify({ - buster: '', - state: { dataUpdatedAt: Date.now(), data: '' }, - }), + JSON.stringify(createPersistedQuery({ data: '' })), ) await persister.persisterFn(queryFn, context, query) @@ -371,10 +404,7 @@ describe('createPersister', () => { await storage.setItem( storageKey, - JSON.stringify({ - buster: '', - state: { dataUpdatedAt }, - }), + JSON.stringify(createPersistedQuery({ dataUpdatedAt })), ) await persister.persisterFn(queryFn, context, query) @@ -398,10 +428,7 @@ describe('createPersister', () => { await storage.setItem( storageKey, - JSON.stringify({ - buster: '', - state: { dataUpdatedAt: Date.now(), data: '' }, - }), + JSON.stringify(createPersistedQuery({ data: '' })), ) await persister.persisterFn(queryFn, context, query) @@ -528,7 +555,7 @@ describe('createPersister', () => { }) describe('retrieveQuery', () => { - it.each(['null', '{}', '{"state":null}'])( + it.each(getMalformedEntries())( 'should remove malformed entry %s without restoring it', async (value) => { const storage = getFreshStorage() @@ -544,6 +571,34 @@ describe('createPersister', () => { }, ) + describe.each(['dataUpdatedAt', 'errorUpdatedAt'])( + 'with non-finite %s from a custom deserializer', + (field) => { + it.each([NaN, Infinity, -Infinity])( + 'should remove the entry with %s without scheduling restoration', + async (value) => { + const storage = getFreshStorage() + const { persister, queryHash, storageKey } = setupPersister( + ['foo'], + { + storage, + deserialize: () => createPersistedQuery({ [field]: value }), + }, + ) + const afterRestore = vi.fn() + await storage.setItem(storageKey, 'stored') + + expect( + await persister.retrieveQuery(queryHash, afterRestore), + ).toBeUndefined() + expect(await storage.getItem(storageKey)).toBeUndefined() + await vi.advanceTimersByTimeAsync(0) + expect(afterRestore).not.toHaveBeenCalled() + }, + ) + }, + ) + it('should return the persisted data when called without a restore callback', async () => { const storage = getFreshStorage() const { persister, client, queryHash, queryKey } = setupPersister( @@ -608,7 +663,7 @@ describe('createPersister', () => { expect(await storage.entries()).toHaveLength(1) }) - it.each(['null', '{}', '{"state":null}'])( + it.each(getMalformedEntries())( 'should remove malformed entry %s and continue garbage collection', async (value) => { const storage = getFreshStorage() @@ -635,6 +690,26 @@ describe('createPersister', () => { }) describe('restoreQueries', () => { + it.each([null, false, 0, '', [], { nested: null }])( + 'should preserve valid data %j during retrieval, restoration, and garbage collection', + async (data) => { + const storage = getFreshStorage() + const { persister, client, queryKey, queryHash, storageKey } = + setupPersister(['foo'], { storage }) + client.setQueryData(queryKey, data) + await persister.persistQueryByKey(queryKey, client) + const storedData = await storage.getItem(storageKey) + client.clear() + + expect(await persister.retrieveQuery(queryHash)).toEqual(data) + await persister.restoreQueries(client) + expect(client.getQueryCache().getAll()).toHaveLength(1) + expect(client.getQueryData(queryKey)).toEqual(data) + await persister.persisterGc() + expect(await storage.getItem(storageKey)).toBe(storedData) + }, + ) + it('should properly clean storage from busted entries', async () => { const storage = getFreshStorage() const { persister, client, query, queryKey } = setupPersister(['foo'], { @@ -770,7 +845,7 @@ describe('createPersister', () => { expect(client.getQueryCache().getAll()).toHaveLength(0) }) - it.each(['null', '{}', '{"state":null}'])( + it.each(getMalformedEntries())( 'should remove malformed entry %s and restore the next query', async (value) => { const storage = getFreshStorage() @@ -797,7 +872,7 @@ describe('createPersister', () => { describe('removeQueries', () => { describe.each([false, true])('with exact: %s', (exact) => { - it.each(['null', '{}', '{"state":null}', '42', '{"state":42}'])( + it.each(getMalformedEntries())( 'should remove malformed entry %s and continue removing matching queries', async (value) => { const storage = getFreshStorage() diff --git a/packages/query-persist-client-core/src/createPersister.ts b/packages/query-persist-client-core/src/createPersister.ts index 6fa8e2d4dab..1b07f7ebb64 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -149,17 +149,30 @@ export function experimental_createQueryPersister({ filters, }: StoragePersisterOptions) { /** - * Checks that a deserialized value has a non-null object state. + * Checks the fields used to filter, expire, and restore a deserialized query. * @param value - The deserialized value to check. * @returns `true` if the value can be processed as a persisted query. */ - function isPersistedQuery(value: unknown): value is PersistedQuery { + function isValidPersistedQuery(value: unknown) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false + } + + const persistedQuery = value as Partial> & { + state?: Partial | null + } + const state = persistedQuery.state + return ( - typeof value === 'object' && - value !== null && - 'state' in value && - typeof value.state === 'object' && - value.state !== null + typeof persistedQuery.buster === 'string' && + typeof persistedQuery.queryHash === 'string' && + Array.isArray(persistedQuery.queryKey) && + typeof state === 'object' && + state !== null && + !Array.isArray(state) && + state.data !== undefined && + Number.isFinite(state.dataUpdatedAt) && + Number.isFinite(state.errorUpdatedAt) ) } @@ -205,7 +218,7 @@ export function experimental_createQueryPersister({ let persistedQuery: PersistedQuery try { persistedQuery = await deserialize(storedData) - if (!isPersistedQuery(persistedQuery)) { + if (!isValidPersistedQuery(persistedQuery)) { throw new Error('Invalid persisted query') } } catch { @@ -352,7 +365,7 @@ export function experimental_createQueryPersister({ let persistedQuery: PersistedQuery try { persistedQuery = await deserialize(value) - if (!isPersistedQuery(persistedQuery)) { + if (!isValidPersistedQuery(persistedQuery)) { throw new Error('Invalid persisted query') } } catch { @@ -393,7 +406,7 @@ export function experimental_createQueryPersister({ let persistedQuery: PersistedQuery try { persistedQuery = await deserialize(value) - if (!isPersistedQuery(persistedQuery)) { + if (!isValidPersistedQuery(persistedQuery)) { throw new Error('Invalid persisted query') } } catch { @@ -456,7 +469,7 @@ export function experimental_createQueryPersister({ let persistedQuery: PersistedQuery try { persistedQuery = await deserialize(value) - if (!isPersistedQuery(persistedQuery)) { + if (!isValidPersistedQuery(persistedQuery)) { throw new Error('Invalid persisted query') } } catch { diff --git a/packages/react-query/src/__tests__/fine-grained-persister.test.tsx b/packages/react-query/src/__tests__/fine-grained-persister.test.tsx index 9298c0fedeb..93a19737b5c 100644 --- a/packages/react-query/src/__tests__/fine-grained-persister.test.tsx +++ b/packages/react-query/src/__tests__/fine-grained-persister.test.tsx @@ -25,7 +25,6 @@ describe('fine grained persister', () => { it('should restore query state from persister and not refetch', async () => { const key = queryKey() - const hash = hashKey(key) const spy = vi.fn(() => Promise.resolve('Works from queryFn')) const mapStorage = new Map() @@ -41,18 +40,12 @@ describe('fine grained persister', () => { }, } - await storage.setItem( - `${PERSISTER_KEY_PREFIX}-${hash}`, - JSON.stringify({ - buster: '', - queryHash: hash, - queryKey: key, - state: { - dataUpdatedAt: Date.now(), - data: 'Works from persister', - }, - }), + queryClient.setQueryData(key, 'Works from persister') + await experimental_createQueryPersister({ storage }).persistQueryByKey( + key, + queryClient, ) + queryClient.clear() function Test() { const [_ref, setRef] = React.useState() @@ -78,7 +71,6 @@ describe('fine grained persister', () => { it('should restore query state from persister and refetch', async () => { const key = queryKey() - const hash = hashKey(key) const spy = vi.fn(() => sleep(5).then(() => 'Works from queryFn')) const mapStorage = new Map() @@ -94,18 +86,12 @@ describe('fine grained persister', () => { }, } - await storage.setItem( - `${PERSISTER_KEY_PREFIX}-${hash}`, - JSON.stringify({ - buster: '', - queryHash: hash, - queryKey: key, - state: { - dataUpdatedAt: Date.now(), - data: 'Works from persister', - }, - }), + queryClient.setQueryData(key, 'Works from persister') + await experimental_createQueryPersister({ storage }).persistQueryByKey( + key, + queryClient, ) + queryClient.clear() function Test() { const [_ref, setRef] = React.useState() From 684bfc725fbf612b1aeb36f667955523122c610d Mon Sep 17 00:00:00 2001 From: VGontier <57801283+VGontier-cmd@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:41:12 +0200 Subject: [PATCH 4/4] fix(query-persist-client-core): narrow persisted entry validation --- .changeset/proud-donuts-shave.md | 2 +- .../react/plugins/createPersister.md | 2 +- docs/framework/vue/plugins/createPersister.md | 2 +- .../__tests__/fine-grained-persister.test.tsx | 34 +++++--- .../src/__tests__/createPersister.test.ts | 77 ++++++------------- .../src/createPersister.ts | 5 +- .../__tests__/fine-grained-persister.test.tsx | 34 +++++--- 7 files changed, 74 insertions(+), 82 deletions(-) diff --git a/.changeset/proud-donuts-shave.md b/.changeset/proud-donuts-shave.md index 52021478b91..666919747cd 100644 --- a/.changeset/proud-donuts-shave.md +++ b/.changeset/proud-donuts-shave.md @@ -2,4 +2,4 @@ "@tanstack/query-persist-client-core": patch --- -Validate persisted query keys, hashes, busters, data, and timestamps before retrieval, restoration, garbage collection, or filtered removal. Remove malformed entries without interrupting subsequent queries, while preserving valid `null` data. +Validate persisted query keys, hashes, state objects, and data update timestamps before retrieval, restoration, garbage collection, or filtered removal. Remove malformed entries without interrupting subsequent queries, while preserving valid `null` data. diff --git a/docs/framework/react/plugins/createPersister.md b/docs/framework/react/plugins/createPersister.md index 8fda8722f61..7508d175748 100644 --- a/docs/framework/react/plugins/createPersister.md +++ b/docs/framework/react/plugins/createPersister.md @@ -104,7 +104,7 @@ useMutation({ 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. -Persisted entries must have a string `buster` and `queryHash`, an array `queryKey`, and a `state` object with defined `data` and finite numeric `dataUpdatedAt` and `errorUpdatedAt` values. `null` is valid query data. Entries that do not meet these requirements are removed during retrieval, garbage collection, restoration, and filtered removal, without interrupting processing of other entries. +Persisted entries must have a string `queryHash`, an array `queryKey`, and a `state` object with a finite numeric `dataUpdatedAt`. Entries that do not meet these requirements are removed during retrieval, garbage collection, restoration, and filtered removal, without interrupting processing of other entries. `null` remains valid query data. ### `persisterGc(): Promise` diff --git a/docs/framework/vue/plugins/createPersister.md b/docs/framework/vue/plugins/createPersister.md index 08f133c845a..73017437afd 100644 --- a/docs/framework/vue/plugins/createPersister.md +++ b/docs/framework/vue/plugins/createPersister.md @@ -101,7 +101,7 @@ useMutation({ 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. -Persisted entries must have a string `buster` and `queryHash`, an array `queryKey`, and a `state` object with defined `data` and finite numeric `dataUpdatedAt` and `errorUpdatedAt` values. `null` is valid query data. Entries that do not meet these requirements are removed during retrieval, garbage collection, restoration, and filtered removal, without interrupting processing of other entries. +Persisted entries must have a string `queryHash`, an array `queryKey`, and a `state` object with a finite numeric `dataUpdatedAt`. Entries that do not meet these requirements are removed during retrieval, garbage collection, restoration, and filtered removal, without interrupting processing of other entries. `null` remains valid query data. ### `persisterGc(): Promise` diff --git a/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx b/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx index db806a8eba5..fb6538b26a3 100644 --- a/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx +++ b/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx @@ -26,6 +26,7 @@ describe('fine grained persister', () => { it('should restore query state from persister and not refetch', async () => { const key = queryKey() + const hash = hashKey(key) const spy = vi.fn(() => Promise.resolve('Works from queryFn')) const mapStorage = new Map() @@ -41,12 +42,18 @@ describe('fine grained persister', () => { }, } - queryClient.setQueryData(key, 'Works from persister') - await experimental_createQueryPersister({ storage }).persistQueryByKey( - key, - queryClient, + await storage.setItem( + `${PERSISTER_KEY_PREFIX}-${hash}`, + JSON.stringify({ + buster: '', + queryHash: hash, + queryKey: key, + state: { + dataUpdatedAt: Date.now(), + data: 'Works from persister', + }, + }), ) - queryClient.clear() function Test() { const [_ref, setRef] = useState() @@ -72,6 +79,7 @@ describe('fine grained persister', () => { it('should restore query state from persister and refetch', async () => { const key = queryKey() + const hash = hashKey(key) const spy = vi.fn(() => sleep(5).then(() => 'Works from queryFn')) const mapStorage = new Map() @@ -87,12 +95,18 @@ describe('fine grained persister', () => { }, } - queryClient.setQueryData(key, 'Works from persister') - await experimental_createQueryPersister({ storage }).persistQueryByKey( - key, - queryClient, + await storage.setItem( + `${PERSISTER_KEY_PREFIX}-${hash}`, + JSON.stringify({ + buster: '', + queryHash: hash, + queryKey: key, + state: { + dataUpdatedAt: Date.now(), + data: 'Works from persister', + }, + }), ) - queryClient.clear() function Test() { const [_ref, setRef] = useState() 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 7d1db02728a..4423a94d155 100644 --- a/packages/query-persist-client-core/src/__tests__/createPersister.test.ts +++ b/packages/query-persist-client-core/src/__tests__/createPersister.test.ts @@ -93,19 +93,14 @@ function getMalformedEntries() { null, {}, 42, - { state: null }, - { state: 42 }, - { ...persistedQuery, buster: 42 }, + { ...persistedQuery, state: null }, + { ...persistedQuery, state: 42 }, { ...persistedQuery, queryHash: undefined }, { ...persistedQuery, queryKey: 'foo' }, - ...[ - { data: undefined }, - { dataUpdatedAt: {} }, - { errorUpdatedAt: 'invalid' }, - ].map((state) => ({ + { ...persistedQuery, - state: { ...persistedQuery.state, ...state }, - })), + state: { ...persistedQuery.state, dataUpdatedAt: {} }, + }, ] return values.map((value) => JSON.stringify(value)) @@ -571,31 +566,23 @@ describe('createPersister', () => { }, ) - describe.each(['dataUpdatedAt', 'errorUpdatedAt'])( - 'with non-finite %s from a custom deserializer', - (field) => { - it.each([NaN, Infinity, -Infinity])( - 'should remove the entry with %s without scheduling restoration', - async (value) => { - const storage = getFreshStorage() - const { persister, queryHash, storageKey } = setupPersister( - ['foo'], - { - storage, - deserialize: () => createPersistedQuery({ [field]: value }), - }, - ) - const afterRestore = vi.fn() - await storage.setItem(storageKey, 'stored') - - expect( - await persister.retrieveQuery(queryHash, afterRestore), - ).toBeUndefined() - expect(await storage.getItem(storageKey)).toBeUndefined() - await vi.advanceTimersByTimeAsync(0) - expect(afterRestore).not.toHaveBeenCalled() - }, - ) + it.each([NaN, Infinity, -Infinity])( + 'should remove an entry with dataUpdatedAt %s from a custom deserializer without scheduling restoration', + async (value) => { + const storage = getFreshStorage() + const { persister, queryHash, storageKey } = setupPersister(['foo'], { + storage, + deserialize: () => createPersistedQuery({ dataUpdatedAt: value }), + }) + const afterRestore = vi.fn() + await storage.setItem(storageKey, 'stored') + + expect( + await persister.retrieveQuery(queryHash, afterRestore), + ).toBeUndefined() + expect(await storage.getItem(storageKey)).toBeUndefined() + await vi.advanceTimersByTimeAsync(0) + expect(afterRestore).not.toHaveBeenCalled() }, ) @@ -690,26 +677,6 @@ describe('createPersister', () => { }) describe('restoreQueries', () => { - it.each([null, false, 0, '', [], { nested: null }])( - 'should preserve valid data %j during retrieval, restoration, and garbage collection', - async (data) => { - const storage = getFreshStorage() - const { persister, client, queryKey, queryHash, storageKey } = - setupPersister(['foo'], { storage }) - client.setQueryData(queryKey, data) - await persister.persistQueryByKey(queryKey, client) - const storedData = await storage.getItem(storageKey) - client.clear() - - expect(await persister.retrieveQuery(queryHash)).toEqual(data) - await persister.restoreQueries(client) - expect(client.getQueryCache().getAll()).toHaveLength(1) - expect(client.getQueryData(queryKey)).toEqual(data) - await persister.persisterGc() - expect(await storage.getItem(storageKey)).toBe(storedData) - }, - ) - it('should properly clean storage from busted entries', async () => { const storage = getFreshStorage() const { persister, client, query, queryKey } = setupPersister(['foo'], { diff --git a/packages/query-persist-client-core/src/createPersister.ts b/packages/query-persist-client-core/src/createPersister.ts index 1b07f7ebb64..4caa7e91542 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -164,15 +164,12 @@ export function experimental_createQueryPersister({ const state = persistedQuery.state return ( - typeof persistedQuery.buster === 'string' && typeof persistedQuery.queryHash === 'string' && Array.isArray(persistedQuery.queryKey) && typeof state === 'object' && state !== null && !Array.isArray(state) && - state.data !== undefined && - Number.isFinite(state.dataUpdatedAt) && - Number.isFinite(state.errorUpdatedAt) + Number.isFinite(state.dataUpdatedAt) ) } diff --git a/packages/react-query/src/__tests__/fine-grained-persister.test.tsx b/packages/react-query/src/__tests__/fine-grained-persister.test.tsx index 93a19737b5c..9298c0fedeb 100644 --- a/packages/react-query/src/__tests__/fine-grained-persister.test.tsx +++ b/packages/react-query/src/__tests__/fine-grained-persister.test.tsx @@ -25,6 +25,7 @@ describe('fine grained persister', () => { it('should restore query state from persister and not refetch', async () => { const key = queryKey() + const hash = hashKey(key) const spy = vi.fn(() => Promise.resolve('Works from queryFn')) const mapStorage = new Map() @@ -40,12 +41,18 @@ describe('fine grained persister', () => { }, } - queryClient.setQueryData(key, 'Works from persister') - await experimental_createQueryPersister({ storage }).persistQueryByKey( - key, - queryClient, + await storage.setItem( + `${PERSISTER_KEY_PREFIX}-${hash}`, + JSON.stringify({ + buster: '', + queryHash: hash, + queryKey: key, + state: { + dataUpdatedAt: Date.now(), + data: 'Works from persister', + }, + }), ) - queryClient.clear() function Test() { const [_ref, setRef] = React.useState() @@ -71,6 +78,7 @@ describe('fine grained persister', () => { it('should restore query state from persister and refetch', async () => { const key = queryKey() + const hash = hashKey(key) const spy = vi.fn(() => sleep(5).then(() => 'Works from queryFn')) const mapStorage = new Map() @@ -86,12 +94,18 @@ describe('fine grained persister', () => { }, } - queryClient.setQueryData(key, 'Works from persister') - await experimental_createQueryPersister({ storage }).persistQueryByKey( - key, - queryClient, + await storage.setItem( + `${PERSISTER_KEY_PREFIX}-${hash}`, + JSON.stringify({ + buster: '', + queryHash: hash, + queryKey: key, + state: { + dataUpdatedAt: Date.now(), + data: 'Works from persister', + }, + }), ) - queryClient.clear() function Test() { const [_ref, setRef] = React.useState()