diff --git a/.changeset/proud-donuts-shave.md b/.changeset/proud-donuts-shave.md new file mode 100644 index 00000000000..666919747cd --- /dev/null +++ b/.changeset/proud-donuts-shave.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-persist-client-core": patch +--- + +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 e98b4377064..7508d175748 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 `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` 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..73017437afd 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 `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` This function can be used to sporadically clean up storage from `expired`, `busted` or `malformed` entries. 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..4423a94d155 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,43 @@ 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, + { ...persistedQuery, state: null }, + { ...persistedQuery, state: 42 }, + { ...persistedQuery, queryHash: undefined }, + { ...persistedQuery, queryKey: 'foo' }, + { + ...persistedQuery, + state: { ...persistedQuery.state, dataUpdatedAt: {} }, + }, + ] + + return values.map((value) => JSON.stringify(value)) +} + describe('createPersister', () => { beforeAll(() => { vi.useFakeTimers() @@ -155,10 +199,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 +220,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 +241,8 @@ describe('createPersister', () => { await storage.setItem( storageKey, JSON.stringify({ + ...createPersistedQuery(), buster: 'bust', - state: { dataUpdatedAt: Date.now() }, }), ) @@ -227,10 +265,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 +290,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 +314,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 +340,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 +399,7 @@ describe('createPersister', () => { await storage.setItem( storageKey, - JSON.stringify({ - buster: '', - state: { dataUpdatedAt }, - }), + JSON.stringify(createPersistedQuery({ dataUpdatedAt })), ) await persister.persisterFn(queryFn, context, query) @@ -398,10 +423,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,6 +550,42 @@ describe('createPersister', () => { }) describe('retrieveQuery', () => { + it.each(getMalformedEntries())( + '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.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() + }, + ) + it('should return the persisted data when called without a restore callback', async () => { const storage = getFreshStorage() const { persister, client, queryHash, queryKey } = setupPersister( @@ -591,6 +649,31 @@ describe('createPersister', () => { await persister.persisterGc() expect(await storage.entries()).toHaveLength(1) }) + + it.each(getMalformedEntries())( + '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,9 +811,63 @@ describe('createPersister', () => { expect(await storage.entries()).toHaveLength(0) expect(client.getQueryCache().getAll()).toHaveLength(0) }) + + it.each(getMalformedEntries())( + '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', () => { + describe.each([false, true])('with exact: %s', (exact) => { + it.each(getMalformedEntries())( + '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 8a89dc351e2..4caa7e91542 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -148,6 +148,31 @@ export function experimental_createQueryPersister({ refetchOnRestore = true, filters, }: StoragePersisterOptions) { + /** + * 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 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 persistedQuery.queryHash === 'string' && + Array.isArray(persistedQuery.queryKey) && + typeof state === 'object' && + state !== null && + !Array.isArray(state) && + Number.isFinite(state.dataUpdatedAt) + ) + } + /** * Checks whether a persisted query should be discarded. * @param persistedQuery - The persisted query to check. @@ -190,6 +215,9 @@ export function experimental_createQueryPersister({ let persistedQuery: PersistedQuery try { persistedQuery = await deserialize(storedData) + if (!isValidPersistedQuery(persistedQuery)) { + throw new Error('Invalid persisted query') + } } catch { await storage.removeItem(storageKey) return @@ -334,6 +362,9 @@ export function experimental_createQueryPersister({ let persistedQuery: PersistedQuery try { persistedQuery = await deserialize(value) + if (!isValidPersistedQuery(persistedQuery)) { + throw new Error('Invalid persisted query') + } } catch { await storage.removeItem(key) continue @@ -372,6 +403,9 @@ export function experimental_createQueryPersister({ let persistedQuery: PersistedQuery try { persistedQuery = await deserialize(value) + if (!isValidPersistedQuery(persistedQuery)) { + throw new Error('Invalid persisted query') + } } catch { await storage.removeItem(key) continue @@ -432,6 +466,9 @@ export function experimental_createQueryPersister({ let persistedQuery: PersistedQuery try { persistedQuery = await deserialize(value) + if (!isValidPersistedQuery(persistedQuery)) { + throw new Error('Invalid persisted query') + } } catch { await storage.removeItem(key) continue