snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
autofix.ci / autofix (push) Failing after 4s
Release / Release (push) Has been skipped
GitHub Actions Security Analysis / Run zizmor (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:13:08 +05:00
commit 3a013ccdfe
3570 changed files with 490194 additions and 0 deletions

View File

@@ -0,0 +1,472 @@
# @tanstack/query-persist-client-core
## 5.102.8
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.102.8
## 5.102.7
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.102.7
## 5.102.6
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.102.6
## 5.102.5
### Patch Changes
- Updated dependencies [[`578e5c2`](https://github.com/TanStack/query/commit/578e5c26e8ebd0d7351b4b8e2bafba695e672b8d)]:
- @tanstack/query-core@5.102.5
## 5.102.4
### Patch Changes
- Updated dependencies [[`a05df6a`](https://github.com/TanStack/query/commit/a05df6aefb0e2489ec2c879ae16e2ee7cb3123ec)]:
- @tanstack/query-core@5.102.4
## 5.102.3
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.102.3
## 5.102.2
### Patch Changes
- Updated dependencies [[`80fbf73`](https://github.com/TanStack/query/commit/80fbf73e77892d702c107e14a84c219a8ed825dc)]:
- @tanstack/query-core@5.102.2
## 5.102.1
### Patch Changes
- Updated dependencies [[`134890d`](https://github.com/TanStack/query/commit/134890dbdc60e4fb0313e44b512d29284ca82f96)]:
- @tanstack/query-core@5.102.1
## 5.102.0
### Patch Changes
- Updated dependencies [[`34f7cee`](https://github.com/TanStack/query/commit/34f7ceed09c10e4a3aa2df31a106ddf02ec4e787), [`b4368c4`](https://github.com/TanStack/query/commit/b4368c43792349f6c29d1fb41f7ee1ef3a8bdd2c), [`5bb089d`](https://github.com/TanStack/query/commit/5bb089d153d33933983b7ea80f8d69b51d423698), [`ba4650c`](https://github.com/TanStack/query/commit/ba4650c05e61e33c609e051b948c9f7d31ce70d1), [`294d4e6`](https://github.com/TanStack/query/commit/294d4e62c4f7d674574a4903eef2a1bc3dd1413b), [`1f631b3`](https://github.com/TanStack/query/commit/1f631b3604aab6567fb3f6c90646a5a304641546), [`01a02bf`](https://github.com/TanStack/query/commit/01a02bfad32f9efbc17796be31d6399f1197a655), [`18c1c1e`](https://github.com/TanStack/query/commit/18c1c1ef94883e781ad36d36f9ea4a043ce4260b), [`5448063`](https://github.com/TanStack/query/commit/5448063f828d2270dccd41ae375e1afde35e91f2), [`2215bb0`](https://github.com/TanStack/query/commit/2215bb031139cdc8a84751b37a485c38ca9d2b6e), [`1ef4208`](https://github.com/TanStack/query/commit/1ef42087c9a266c2137d2ef645cbcc662f60ac93), [`5981771`](https://github.com/TanStack/query/commit/5981771abec9330b344a2617543eb05ba7c99e24), [`4a9bef6`](https://github.com/TanStack/query/commit/4a9bef6cf19b1cd6b014032d696129f12a848185), [`bef4bc7`](https://github.com/TanStack/query/commit/bef4bc780ce7cca32b7e3dea85f77d92f82a62a2), [`9656dc4`](https://github.com/TanStack/query/commit/9656dc4e5fef5f8c502579b52459f9e8c72e787c), [`326aaf1`](https://github.com/TanStack/query/commit/326aaf1333e5d9cbc46569c53f218d31684162d2), [`3e83601`](https://github.com/TanStack/query/commit/3e836010032c2586e9f1c66a271fa9114a6401f9), [`c6fc17c`](https://github.com/TanStack/query/commit/c6fc17cfad6e452261c585fabfb8c210e60531ed)]:
- @tanstack/query-core@5.102.0
## 5.101.4
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.101.4
## 5.101.3
### Patch Changes
- Updated dependencies [[`7e3c822`](https://github.com/TanStack/query/commit/7e3c822a10896f41a8f1031c16b85096277af677)]:
- @tanstack/query-core@5.101.3
## 5.101.2
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.101.2
## 5.101.1
### Patch Changes
- Updated dependencies [[`9eff92e`](https://github.com/TanStack/query/commit/9eff92ed86e284ec0125b3a3539d028688235bd1)]:
- @tanstack/query-core@5.101.1
## 5.101.0
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.101.0
## 5.100.14
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.100.14
## 5.100.13
### Patch Changes
- Updated dependencies [[`d423168`](https://github.com/TanStack/query/commit/d423168f6261a5cb3d353e53b27c8150cc271151)]:
- @tanstack/query-core@5.100.13
## 5.100.12
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.100.12
## 5.100.11
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.100.11
## 5.100.10
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.100.10
## 5.100.9
### Patch Changes
- Updated dependencies [[`fcee7bd`](https://github.com/TanStack/query/commit/fcee7bdc429385ae8ffa224fa8a7a9ec7b8ee380)]:
- @tanstack/query-core@5.100.9
## 5.100.8
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.100.8
## 5.100.7
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.100.7
## 5.100.6
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.100.6
## 5.100.5
### Patch Changes
- Updated dependencies [[`a53ef97`](https://github.com/TanStack/query/commit/a53ef97f87decb8ea2431710f5199431d3c94c8d)]:
- @tanstack/query-core@5.100.5
## 5.100.4
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.100.4
## 5.100.3
### Patch Changes
- Updated dependencies [[`f85d825`](https://github.com/TanStack/query/commit/f85d825e02efbbff02e2081528ed28f5e5382f7a)]:
- @tanstack/query-core@5.100.3
## 5.100.2
### Patch Changes
- Updated dependencies [[`ea4497e`](https://github.com/TanStack/query/commit/ea4497e8aa00d8c1c3a36fb1e17563a889d6ab31), [`d6a7bf3`](https://github.com/TanStack/query/commit/d6a7bf3e3e024c1a77d0536813238cc8007a5fa7), [`645d5d1`](https://github.com/TanStack/query/commit/645d5d130f5e8017cb1bf1a37987f7b980aed705)]:
- @tanstack/query-core@5.100.2
## 5.100.1
### Patch Changes
- Updated dependencies [[`1bb0d23`](https://github.com/TanStack/query/commit/1bb0d234280fd4ae1725c439088426a20593a8df)]:
- @tanstack/query-core@5.100.1
## 5.100.0
### Patch Changes
- Updated dependencies [[`6540a41`](https://github.com/TanStack/query/commit/6540a4126b1c087d86d64525e78f32d9920dcd31)]:
- @tanstack/query-core@5.100.0
## 5.99.2
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.99.2
## 5.99.1
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.99.1
## 5.99.0
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.99.0
## 5.98.0
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.98.0
## 5.97.0
### Patch Changes
- Updated dependencies [[`2bfb12c`](https://github.com/TanStack/query/commit/2bfb12cc44f1d8495106136e4ddacb817135f8f9)]:
- @tanstack/query-core@5.97.0
## 5.96.2
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.96.2
## 5.96.1
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.96.1
## 5.96.0
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.96.0
## 5.95.2
### Patch Changes
- Updated dependencies [[`cd5a35b`](https://github.com/TanStack/query/commit/cd5a35b328837781aa4f9305bb2bd7877ca934e9)]:
- @tanstack/query-core@5.95.2
## 5.95.1
### Patch Changes
- Updated dependencies [[`1f1775c`](https://github.com/TanStack/query/commit/1f1775ca92f2b6c035682947ff3b3424804ff31a)]:
- @tanstack/query-core@5.95.1
## 5.95.0
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.95.0
## 5.94.5
### Patch Changes
- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312))
- Updated dependencies [[`4b6536d`](https://github.com/TanStack/query/commit/4b6536dfce99036f4e37f52943c6fed3ad0e0a18)]:
- @tanstack/query-core@5.94.5
## 5.94.4
### Patch Changes
- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064))
- Updated dependencies [[`4c75210`](https://github.com/TanStack/query/commit/4c75210ce8235fe3d39b67e1029eff11278927cc)]:
- @tanstack/query-core@5.94.4
## 5.92.4
### Patch Changes
- fix(streamedQuery): maintain error state on reset refetch with initialData defined ([#10287](https://github.com/TanStack/query/pull/10287))
- Updated dependencies [[`248975e`](https://github.com/TanStack/query/commit/248975e896f585f6eaa505c796e73fcf7bfd1eec)]:
- @tanstack/query-core@5.91.2
## 5.92.3
### Patch Changes
- Updated dependencies [[`a89aab9`](https://github.com/TanStack/query/commit/a89aab975581c25c113a26c8af486b4cafad272a)]:
- @tanstack/query-core@5.91.1
## 5.92.2
### Patch Changes
- Updated dependencies [[`6fa901b`](https://github.com/TanStack/query/commit/6fa901b97a22a80d0fca3f6ed86237ff0cbdd13b)]:
- @tanstack/query-core@5.91.0
## 5.92.1
### Patch Changes
- removeItem if deserialize fails ([#10190](https://github.com/TanStack/query/pull/10190))
## 5.92.0
### Minor Changes
- Add removeQueries to experimental_createQueryPersister ([#10186](https://github.com/TanStack/query/pull/10186))
## 5.91.19
### Patch Changes
- Updated dependencies [[`e7258c5`](https://github.com/TanStack/query/commit/e7258c5cb30cafa456cdb4e6bc75b43bf619954d)]:
- @tanstack/query-core@5.90.20
## 5.91.18
### Patch Changes
- Updated dependencies [[`53fc74e`](https://github.com/TanStack/query/commit/53fc74ebb16730bd3317f039a69c6821386bae93)]:
- @tanstack/query-core@5.90.19
## 5.91.17
### Patch Changes
- Updated dependencies [[`dea1614`](https://github.com/TanStack/query/commit/dea1614aaad5c572cf43cea54b64ac09dc4d5b41)]:
- @tanstack/query-core@5.90.18
## 5.91.16
### Patch Changes
- Updated dependencies [[`269351b`](https://github.com/TanStack/query/commit/269351b8ce4b4846da3d320ac5b850ee6aada0d6)]:
- @tanstack/query-core@5.90.17
## 5.91.15
### Patch Changes
- Updated dependencies [[`7f47906`](https://github.com/TanStack/query/commit/7f47906eaccc3f3aa5ce24b77a83bd7a620a237b)]:
- @tanstack/query-core@5.90.16
## 5.91.14
### Patch Changes
- Updated dependencies [[`fccef79`](https://github.com/TanStack/query/commit/fccef797d57d4a9566517bba87c8377f363920f2)]:
- @tanstack/query-core@5.90.15
## 5.91.13
### Patch Changes
- Updated dependencies [[`d576092`](https://github.com/TanStack/query/commit/d576092e2ece4ca3936add3eb0da5234c1d82ed4)]:
- @tanstack/query-core@5.90.14
## 5.91.12
### Patch Changes
- Updated dependencies [[`4a0a78a`](https://github.com/TanStack/query/commit/4a0a78afbc2432f8cb6828035965853fa98c86a0)]:
- @tanstack/query-core@5.90.13
## 5.91.11
### Patch Changes
- Updated dependencies [[`72d8ac5`](https://github.com/TanStack/query/commit/72d8ac5c592004b8f9c3ee086fcb9c3cd615ca05)]:
- @tanstack/query-core@5.90.12
## 5.91.10
### Patch Changes
- Updated dependencies [[`c01b150`](https://github.com/TanStack/query/commit/c01b150e3673e11d6533768529a5e6fe3ebee68c)]:
- @tanstack/query-core@5.90.11
## 5.91.9
### Patch Changes
- Updated dependencies [[`8e2e174`](https://github.com/TanStack/query/commit/8e2e174e9fd2e7b94cd232041e49c9d014d74e26), [`eb559a6`](https://github.com/TanStack/query/commit/eb559a66dc0d77dd46435f624fa64fc068bef9ae)]:
- @tanstack/query-core@5.90.10
## 5.91.8
### Patch Changes
- Updated dependencies [[`08b211f`](https://github.com/TanStack/query/commit/08b211f8aa475e05d2f13a36517fc556861ef962)]:
- @tanstack/query-core@5.90.9
## 5.91.7
### Patch Changes
- Updated dependencies [[`c0ec9fe`](https://github.com/TanStack/query/commit/c0ec9fe0d1426fe3f233adda3ebf23989ffaa110)]:
- @tanstack/query-core@5.90.8
## 5.91.6
### Patch Changes
- Updated dependencies [[`b4cd121`](https://github.com/TanStack/query/commit/b4cd121a39d07cefaa3a3411136d342cc54ce8fb)]:
- @tanstack/query-core@5.90.7
## 5.91.5
### Patch Changes
- Updated dependencies [[`1638c02`](https://github.com/TanStack/query/commit/1638c028df55648995d04431179904371a189772)]:
- @tanstack/query-core@5.90.6
## 5.91.4
### Patch Changes
- Updated dependencies [[`e42ddfe`](https://github.com/TanStack/query/commit/e42ddfe919f34f847ca101aeef162c69845f9a1e)]:
- @tanstack/query-core@5.90.5
## 5.91.3
### Patch Changes
- Updated dependencies [[`20ef922`](https://github.com/TanStack/query/commit/20ef922a0a7c3aee00150bf69123c338b0922922)]:
- @tanstack/query-core@5.90.4
## 5.91.2
### Patch Changes
- Updated dependencies [[`4e1c433`](https://github.com/TanStack/query/commit/4e1c4338a72f7384600bbda99e44bc1891695df4)]:
- @tanstack/query-core@5.90.3
## 5.91.1
### Patch Changes
- allow restoring null values ([#9747](https://github.com/TanStack/query/pull/9747))
## 5.91.0
### Minor Changes
- Added a refetchOnRestore setting to control refetching after restoring persisted queries ([#9745](https://github.com/TanStack/query/pull/9745))

View File

@@ -0,0 +1,5 @@
// @ts-check
import rootConfig from './root.eslint.config.js'
export default [...rootConfig]

View File

@@ -0,0 +1,65 @@
{
"name": "@tanstack/query-persist-client-core",
"version": "5.102.8",
"description": "Set of utilities for interacting with persisters, which can save your queryClient for later use",
"author": "tannerlinsley",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/TanStack/query.git",
"directory": "packages/query-persist-client-core"
},
"homepage": "https://tanstack.com/query",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"scripts": {
"clean": "premove ./build ./coverage ./dist-ts ./.cache/test-types",
"compile": "tsc --project tsconfig.json",
"test:eslint": "eslint --concurrency=auto ./src",
"test:types": "npm-run-all --serial test:types:*",
"test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts56 --tsBuildInfoFile .cache/test-types/ts56.tsbuildinfo",
"test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts57 --tsBuildInfoFile .cache/test-types/ts57.tsbuildinfo",
"test:types:ts58": "node ../../node_modules/typescript58/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts58 --tsBuildInfoFile .cache/test-types/ts58.tsbuildinfo",
"test:types:ts59": "node ../../node_modules/typescript59/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts59 --tsBuildInfoFile .cache/test-types/ts59.tsbuildinfo",
"test:types:tscurrent": "tsc --project tsconfig.json --outDir .cache/test-types/tscurrent --tsBuildInfoFile .cache/test-types/tscurrent.tsbuildinfo",
"test:types:ts70": "node ../../node_modules/typescript70/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts70 --tsBuildInfoFile .cache/test-types/ts70.tsbuildinfo",
"test:lib": "vitest",
"test:lib:dev": "pnpm run test:lib --watch",
"test:build": "publint --strict && attw --pack",
"build": "tsdown --tsconfig tsconfig.prod.json"
},
"type": "module",
"types": "build/legacy/index.d.ts",
"main": "build/legacy/index.cjs",
"module": "build/legacy/index.js",
"react-native": "src/index.ts",
"exports": {
".": {
"@tanstack/custom-condition": "./src/index.ts",
"import": {
"types": "./build/modern/index.d.ts",
"default": "./build/modern/index.js"
},
"require": {
"types": "./build/modern/index.d.cts",
"default": "./build/modern/index.cjs"
}
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
"build",
"src",
"!src/__tests__"
],
"dependencies": {
"@tanstack/query-core": "workspace:*"
},
"devDependencies": {
"@tanstack/query-test-utils": "workspace:*",
"npm-run-all2": "^5.0.0"
}
}

View File

@@ -0,0 +1 @@
../../eslint.config.js

View File

@@ -0,0 +1 @@
../../scripts/getTsdownConfig.js

View File

@@ -0,0 +1,839 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { Query, 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'
function getFreshStorage() {
const storage = new Map()
return {
getItem: (key: string) => Promise.resolve(storage.get(key)),
setItem: (key: string, value: unknown) => {
storage.set(key, value)
return Promise.resolve()
},
removeItem: (key: string) => {
storage.delete(key)
return Promise.resolve()
},
entries: () => {
return Promise.resolve(Array.from(storage.entries()))
},
}
}
function setupPersister(
queryKey: QueryKey,
persisterOptions: StoragePersisterOptions,
) {
const client = new QueryClient()
const context = {
meta: { foo: 'bar' },
client,
queryKey,
// @ts-expect-error
signal: undefined as AbortSignal,
} satisfies QueryFunctionContext
const queryHash = hashKey(queryKey)
const storageKey = `${PERSISTER_KEY_PREFIX}-${queryHash}`
const queryFn = vi.fn()
const persister = experimental_createQueryPersister(persisterOptions)
const query = new Query({
client,
queryHash,
queryKey,
})
return {
client,
context,
persister,
query,
queryFn,
queryHash,
queryKey,
storageKey,
}
}
describe('createPersister', () => {
beforeAll(() => {
vi.useFakeTimers()
})
afterAll(() => {
vi.useRealTimers()
})
it('should fetch if storage is not provided', async () => {
const { context, persister, query, queryFn } = setupPersister(['foo'], {
storage: undefined,
})
await persister.persisterFn(queryFn, context, query)
expect(queryFn).toHaveBeenCalledExactlyOnceWith(context)
})
it('should fetch if there is no stored data', async () => {
const storage = getFreshStorage()
const { context, persister, query, queryFn } = setupPersister(['foo'], {
storage,
})
await persister.persisterFn(queryFn, context, query)
expect(queryFn).toHaveBeenCalledExactlyOnceWith(context)
})
it('should fetch if query already has data', async () => {
const storage = getFreshStorage()
const { context, persister, query, queryFn } = setupPersister(['foo'], {
storage,
})
query.state.data = 'baz'
await persister.persisterFn(queryFn, context, query)
expect(queryFn).toHaveBeenCalledExactlyOnceWith(context)
})
it('should fetch if deserialization fails', async () => {
const storage = getFreshStorage()
const { context, persister, query, queryFn, storageKey } = setupPersister(
['foo'],
{
storage,
},
)
await storage.setItem(storageKey, '{invalid[item')
await persister.persisterFn(queryFn, context, query)
expect(await storage.getItem(storageKey)).toBeUndefined()
expect(queryFn).toHaveBeenCalledExactlyOnceWith(context)
})
it('should remove stored item if `dataUpdatedAt` is empty', async () => {
const storage = getFreshStorage()
const { context, persister, query, queryFn, storageKey } = setupPersister(
['foo'],
{
storage,
},
)
await storage.setItem(
storageKey,
JSON.stringify({
buster: '',
state: { dataUpdatedAt: undefined },
}),
)
await persister.persisterFn(queryFn, context, query)
expect(await storage.getItem(storageKey)).toBeUndefined()
expect(queryFn).toHaveBeenCalledExactlyOnceWith(context)
})
it('should remove stored item if its expired', async () => {
const storage = getFreshStorage()
const { context, persister, query, queryFn, storageKey } = setupPersister(
['foo'],
{
storage,
maxAge: 100,
},
)
await storage.setItem(
storageKey,
JSON.stringify({
buster: '',
state: { dataUpdatedAt: Date.now() - 200 },
}),
)
await persister.persisterFn(queryFn, context, query)
expect(await storage.getItem(storageKey)).toBeUndefined()
expect(queryFn).toHaveBeenCalledExactlyOnceWith(context)
})
it('should remove stored item if its busted', async () => {
const storage = getFreshStorage()
const { context, persister, query, queryFn, storageKey } = setupPersister(
['foo'],
{
storage,
},
)
await storage.setItem(
storageKey,
JSON.stringify({
buster: 'bust',
state: { dataUpdatedAt: Date.now() },
}),
)
await persister.persisterFn(queryFn, context, query)
expect(await storage.getItem(storageKey)).toBeUndefined()
expect(queryFn).toHaveBeenCalledExactlyOnceWith(context)
})
it('should restore item from the storage and set proper `updatedAt` values', async () => {
const storage = getFreshStorage()
const { context, persister, query, queryFn, storageKey } = setupPersister(
['foo'],
{
storage,
},
)
const dataUpdatedAt = Date.now()
await storage.setItem(
storageKey,
JSON.stringify({
buster: '',
state: { dataUpdatedAt, data: '' },
}),
)
await persister.persisterFn(queryFn, context, query)
query.state.data = 'data0'
query.fetch = vi.fn()
expect(query.state.dataUpdatedAt).toEqual(0)
await vi.advanceTimersByTimeAsync(0)
expect(queryFn).toHaveBeenCalledTimes(0)
expect(query.fetch).toHaveBeenCalledTimes(0)
expect(query.state.dataUpdatedAt).toEqual(dataUpdatedAt)
})
it('should restore item from the storage and refetch when `stale`', async () => {
const storage = getFreshStorage()
const { context, persister, query, queryFn, storageKey } = setupPersister(
['foo'],
{
storage,
},
)
await storage.setItem(
storageKey,
JSON.stringify({
buster: '',
state: { dataUpdatedAt: Date.now(), data: '' },
}),
)
await persister.persisterFn(queryFn, context, query)
query.state.isInvalidated = true
query.fetch = vi.fn()
await vi.advanceTimersByTimeAsync(0)
expect(queryFn).toHaveBeenCalledTimes(0)
expect(query.fetch).toHaveBeenCalledTimes(1)
})
it('should restore item from the storage and refetch when `refetchOnRestore` is set to `always`', async () => {
const storage = getFreshStorage()
const { context, persister, query, queryFn, storageKey } = setupPersister(
['foo'],
{
storage,
refetchOnRestore: 'always',
},
)
await storage.setItem(
storageKey,
JSON.stringify({
buster: '',
state: { dataUpdatedAt: Date.now() + 1000, data: '' },
}),
)
await persister.persisterFn(queryFn, context, query)
query.state.isInvalidated = true
query.fetch = vi.fn()
await vi.advanceTimersByTimeAsync(0)
expect(queryFn).toHaveBeenCalledTimes(0)
expect(query.fetch).toHaveBeenCalledTimes(1)
})
it('should restore item from the storage and NOT refetch when `refetchOnRestore` is set to false', async () => {
const storage = getFreshStorage()
const { context, persister, query, queryFn, storageKey } = setupPersister(
['foo'],
{
storage,
refetchOnRestore: false,
},
)
await storage.setItem(
storageKey,
JSON.stringify({
buster: '',
state: { dataUpdatedAt: Date.now(), data: '' },
}),
)
await persister.persisterFn(queryFn, context, query)
query.state.isInvalidated = true
query.fetch = vi.fn()
await vi.advanceTimersByTimeAsync(0)
expect(queryFn).toHaveBeenCalledTimes(0)
expect(query.fetch).toHaveBeenCalledTimes(0)
})
it('should store item after successful fetch', async () => {
const storage = getFreshStorage()
const {
context,
persister,
query,
queryFn,
queryHash,
queryKey,
storageKey,
} = setupPersister(['foo'], {
storage,
})
await persister.persisterFn(queryFn, context, query)
query.setData('baz')
await vi.advanceTimersByTimeAsync(0)
expect(queryFn).toHaveBeenCalledExactlyOnceWith(context)
expect(JSON.parse(await storage.getItem(storageKey))).toMatchObject({
buster: '',
queryHash,
queryKey,
state: {
data: 'baz',
},
})
})
it('should skip stored item if not matched by filters', async () => {
const storage = getFreshStorage()
const { context, persister, query, queryFn, storageKey } = setupPersister(
['foo'],
{
storage,
filters: {
predicate: () => {
return false
},
},
},
)
const dataUpdatedAt = Date.now()
await storage.setItem(
storageKey,
JSON.stringify({
buster: '',
state: { dataUpdatedAt },
}),
)
await persister.persisterFn(queryFn, context, query)
query.fetch = vi.fn()
await vi.advanceTimersByTimeAsync(0)
expect(queryFn).toHaveBeenCalledTimes(1)
expect(query.fetch).toHaveBeenCalledTimes(0)
})
it('should restore item from the storage with async deserializer', async () => {
const storage = getFreshStorage()
const { context, persister, query, queryFn, storageKey } = setupPersister(
['foo'],
{
storage,
deserialize: (cachedString: string) =>
new Promise((resolve) => resolve(JSON.parse(cachedString))),
},
)
await storage.setItem(
storageKey,
JSON.stringify({
buster: '',
state: { dataUpdatedAt: Date.now(), data: '' },
}),
)
await persister.persisterFn(queryFn, context, query)
query.state.isInvalidated = true
query.fetch = vi.fn()
await vi.advanceTimersByTimeAsync(0)
expect(queryFn).toHaveBeenCalledTimes(0)
expect(query.fetch).toHaveBeenCalledTimes(1)
})
it('should store item after successful fetch with async serializer', async () => {
const storage = getFreshStorage()
const {
context,
persister,
query,
queryFn,
queryHash,
queryKey,
storageKey,
} = setupPersister(['foo'], {
storage,
serialize: (persistedQuery) =>
new Promise((resolve) => resolve(JSON.stringify(persistedQuery))),
})
await persister.persisterFn(queryFn, context, query)
query.setData('baz')
await vi.advanceTimersByTimeAsync(0)
expect(queryFn).toHaveBeenCalledExactlyOnceWith(context)
expect(JSON.parse(await storage.getItem(storageKey))).toMatchObject({
buster: '',
queryHash,
queryKey,
state: {
data: 'baz',
},
})
})
describe('persistQuery', () => {
it('should properly persist basic query', async () => {
const storage = getFreshStorage()
const { persister, query, queryHash, queryKey, storageKey } =
setupPersister(['foo'], {
storage,
})
query.setData('baz')
await persister.persistQuery(query)
expect(JSON.parse(await storage.getItem(storageKey))).toMatchObject({
buster: '',
queryHash,
queryKey,
state: {
dataUpdateCount: 1,
data: 'baz',
status: 'success',
},
})
})
it('should skip persistence if storage is not provided', async () => {
const serializeMock = vi.fn()
const { persister, query } = setupPersister(['foo'], {
storage: null,
serialize: serializeMock,
})
query.setData('baz')
await persister.persistQuery(query)
expect(serializeMock).toHaveBeenCalledTimes(0)
})
})
describe('persistQueryByKey', () => {
it('should skip persistence if storage is not provided', async () => {
const serializeMock = vi.fn()
const { persister, client, queryKey } = setupPersister(['foo'], {
storage: null,
serialize: serializeMock,
})
client.setQueryData(queryKey, 'baz')
await persister.persistQueryByKey(queryKey, client)
expect(serializeMock).toHaveBeenCalledTimes(0)
})
it('should skip persistence if query was not found', async () => {
const serializeMock = vi.fn()
const storage = getFreshStorage()
const { client, persister, queryKey } = setupPersister(['foo'], {
storage,
serialize: serializeMock,
})
client.setQueryData(queryKey, 'baz')
await persister.persistQueryByKey(['foo2'], client)
expect(serializeMock).toHaveBeenCalledTimes(0)
})
it('should properly persist basic query', async () => {
const storage = getFreshStorage()
const { persister, client, queryHash, queryKey, storageKey } =
setupPersister(['foo'], {
storage,
})
client.setQueryData(queryKey, 'baz')
await persister.persistQueryByKey(queryKey, client)
expect(JSON.parse(await storage.getItem(storageKey))).toMatchObject({
buster: '',
queryHash,
queryKey,
state: {
dataUpdateCount: 1,
data: 'baz',
status: 'success',
},
})
})
})
describe('retrieveQuery', () => {
it('should return the persisted data when called without a restore callback', async () => {
const storage = getFreshStorage()
const { persister, client, queryHash, queryKey } = setupPersister(
['foo'],
{ storage },
)
client.setQueryData(queryKey, 'baz')
await persister.persistQueryByKey(queryKey, client)
const restoredData = await persister.retrieveQuery(queryHash)
expect(restoredData).toBe('baz')
})
})
describe('persisterGc', () => {
it('should properly clean storage from busted entries', async () => {
const storage = getFreshStorage()
const { persister, client, query, queryKey } = setupPersister(['foo'], {
storage,
})
query.setState({
dataUpdatedAt: 1,
data: 'f',
})
client.getQueryCache().add(query)
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
await persister.persisterGc()
expect(await storage.entries()).toHaveLength(0)
})
it('should remove entries that cannot be deserialized', async () => {
const storage = getFreshStorage()
const { persister } = setupPersister(['foo'], { storage })
await storage.setItem(`${PERSISTER_KEY_PREFIX}-["foo"]`, 'not-json{')
expect(await storage.entries()).toHaveLength(1)
await persister.persisterGc()
expect(await storage.entries()).toHaveLength(0)
})
it('should keep entries that are neither expired nor busted', async () => {
const storage = getFreshStorage()
const { persister, client, query, queryKey } = setupPersister(['foo'], {
storage,
})
query.setState({
dataUpdatedAt: Date.now(),
data: 'foo',
})
client.getQueryCache().add(query)
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
await persister.persisterGc()
expect(await storage.entries()).toHaveLength(1)
})
})
describe('restoreQueries', () => {
it('should properly clean storage from busted entries', async () => {
const storage = getFreshStorage()
const { persister, client, query, queryKey } = setupPersister(['foo'], {
storage,
})
query.setState({
dataUpdatedAt: 1,
data: 'f',
})
client.getQueryCache().add(query)
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
await persister.restoreQueries(client)
expect(await storage.entries()).toHaveLength(0)
})
it('should properly restore queries from cache without filters', async () => {
const storage = getFreshStorage()
const { persister, client, queryKey } = setupPersister(['foo'], {
storage,
})
client.setQueryData(queryKey, 'foo')
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
client.clear()
expect(client.getQueryCache().getAll()).toHaveLength(0)
await persister.restoreQueries(client)
expect(client.getQueryCache().getAll()).toHaveLength(1)
expect(client.getQueryData(queryKey)).toEqual('foo')
})
it('should properly restore queries from cache', async () => {
const storage = getFreshStorage()
const { persister, client, queryKey } = setupPersister(['foo', 'bar'], {
storage,
})
client.setQueryData(queryKey, 'foo')
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
client.clear()
expect(client.getQueryCache().getAll()).toHaveLength(0)
await persister.restoreQueries(client, { queryKey })
expect(client.getQueryCache().getAll()).toHaveLength(1)
expect(client.getQueryData(queryKey)).toEqual('foo')
})
it('should not restore queries from cache if there is no match', async () => {
const storage = getFreshStorage()
const { persister, client, queryKey } = setupPersister(['foo', 'bar'], {
storage,
})
client.setQueryData(queryKey, 'foo')
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
client.clear()
expect(client.getQueryCache().getAll()).toHaveLength(0)
await persister.restoreQueries(client, { queryKey: ['bar'] })
expect(client.getQueryCache().getAll()).toHaveLength(0)
})
it('should properly restore queries from cache with partial match', async () => {
const storage = getFreshStorage()
const { persister, client, queryKey } = setupPersister(['foo', 'bar'], {
storage,
})
client.setQueryData(queryKey, 'foo')
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
client.clear()
expect(client.getQueryCache().getAll()).toHaveLength(0)
await persister.restoreQueries(client, { queryKey: ['foo'] })
expect(client.getQueryCache().getAll()).toHaveLength(1)
expect(client.getQueryData(queryKey)).toEqual('foo')
})
it('should not restore queries from cache with exact match if there is no match', async () => {
const storage = getFreshStorage()
const { persister, client, queryKey } = setupPersister(['foo', 'bar'], {
storage,
})
client.setQueryData(queryKey, 'foo')
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
client.clear()
expect(client.getQueryCache().getAll()).toHaveLength(0)
await persister.restoreQueries(client, { queryKey: ['foo'], exact: true })
expect(client.getQueryCache().getAll()).toHaveLength(0)
})
it('should restore queries from cache with exact match', async () => {
const storage = getFreshStorage()
const { persister, client, queryKey } = setupPersister(['foo', 'bar'], {
storage,
})
client.setQueryData(queryKey, 'foo')
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
client.clear()
expect(client.getQueryCache().getAll()).toHaveLength(0)
await persister.restoreQueries(client, {
queryKey: queryKey,
exact: true,
})
expect(client.getQueryCache().getAll()).toHaveLength(1)
})
it('should remove entries that cannot be deserialized', async () => {
const storage = getFreshStorage()
const { persister, client } = setupPersister(['foo'], { storage })
await storage.setItem(`${PERSISTER_KEY_PREFIX}-["foo"]`, 'not-json{')
expect(await storage.entries()).toHaveLength(1)
await persister.restoreQueries(client)
expect(await storage.entries()).toHaveLength(0)
expect(client.getQueryCache().getAll()).toHaveLength(0)
})
})
describe('removeQueries', () => {
it('should remove restore queries from storage without filters', async () => {
const storage = getFreshStorage()
const { persister, client, queryKey } = setupPersister(['foo'], {
storage,
})
client.setQueryData(queryKey, 'foo')
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
await persister.removeQueries()
expect(await storage.entries()).toHaveLength(0)
})
it('should remove queries from storage', async () => {
const storage = getFreshStorage()
const { persister, client, queryKey } = setupPersister(['foo', 'bar'], {
storage,
})
client.setQueryData(queryKey, 'foo')
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
await persister.removeQueries({ queryKey })
expect(await storage.entries()).toHaveLength(0)
})
it('should not remove queries from storage if there is no match', async () => {
const storage = getFreshStorage()
const { persister, client, queryKey } = setupPersister(['foo', 'bar'], {
storage,
})
client.setQueryData(queryKey, 'foo')
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
await persister.removeQueries({ queryKey: ['bar'] })
expect(await storage.entries()).toHaveLength(1)
})
it('should properly remove queries from storage with partial match', async () => {
const storage = getFreshStorage()
const { persister, client, queryKey } = setupPersister(['foo', 'bar'], {
storage,
})
client.setQueryData(queryKey, 'foo')
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
await persister.removeQueries({ queryKey: ['foo'] })
expect(await storage.entries()).toHaveLength(0)
})
it('should not remove queries from storage with exact match if there is no match', async () => {
const storage = getFreshStorage()
const { persister, client, queryKey } = setupPersister(['foo', 'bar'], {
storage,
})
client.setQueryData(queryKey, 'foo')
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
await persister.removeQueries({ queryKey: ['foo'], exact: true })
expect(await storage.entries()).toHaveLength(1)
})
it('should remove queries from storage with exact match', async () => {
const storage = getFreshStorage()
const { persister, client, queryKey } = setupPersister(['foo', 'bar'], {
storage,
})
client.setQueryData(queryKey, 'foo')
await persister.persistQueryByKey(queryKey, client)
expect(await storage.entries()).toHaveLength(1)
await persister.removeQueries({
queryKey: queryKey,
exact: true,
})
expect(await storage.entries()).toHaveLength(0)
})
it('should remove entries that cannot be deserialized', async () => {
const storage = getFreshStorage()
const { persister } = setupPersister(['foo'], { storage })
await storage.setItem(`${PERSISTER_KEY_PREFIX}-["foo"]`, 'not-json{')
expect(await storage.entries()).toHaveLength(1)
await persister.removeQueries({ queryKey: ['foo'] })
expect(await storage.entries()).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,300 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
MutationObserver,
QueriesObserver,
QueryClient,
dehydrate,
} from '@tanstack/query-core'
import {
persistQueryClient,
persistQueryClientRestore,
persistQueryClientSubscribe,
} from '../persist'
import { createMockPersister, createSpyPersister } from './utils'
describe('persist', () => {
let queryClient: QueryClient
beforeEach(() => {
queryClient = new QueryClient()
})
afterEach(() => {
queryClient.clear()
})
describe('persistQueryClientSubscribe', () => {
it('should persist mutations', async () => {
const persister = createMockPersister()
const unsubscribe = persistQueryClientSubscribe({
queryClient,
persister,
dehydrateOptions: { shouldDehydrateMutation: () => true },
})
queryClient.getMutationCache().build(queryClient, {
mutationFn: (text: string) => Promise.resolve(text),
})
const result = await persister.restoreClient()
expect(result?.clientState.mutations).toHaveLength(1)
unsubscribe()
})
})
describe('persistQueryClientSave', () => {
it('should not be triggered on observer type events', () => {
const persister = createSpyPersister()
const unsubscribe = persistQueryClientSubscribe({
queryClient,
persister,
})
const queryKey = ['test']
const queryFn = vi.fn().mockReturnValue(1)
const observer = new QueriesObserver(queryClient, [{ queryKey, queryFn }])
const unsubscribeObserver = observer.subscribe(vi.fn())
observer
.getObservers()[0]
?.setOptions({ queryKey, refetchOnWindowFocus: false })
unsubscribeObserver()
queryClient.setQueryData(queryKey, 2)
// persistClient should be called 3 times:
// 1. When query is added
// 2. When queryFn is resolved
// 3. When setQueryData is called
// All events fired by manipulating observers are ignored
expect(persister.persistClient).toHaveBeenCalledTimes(3)
unsubscribe()
})
it('should not be triggered on mutation observer type events', () => {
const persister = createSpyPersister()
const unsubscribe = persistQueryClientSubscribe({
queryClient,
persister,
})
const observer = new MutationObserver(queryClient, {
mutationFn: () => Promise.resolve('data'),
})
const unsubscribeObserver = observer.subscribe(vi.fn())
observer.setOptions({ mutationKey: ['test'] })
unsubscribeObserver()
// Events fired by manipulating the mutation observer are not cache
// events, so they must not trigger a persist.
expect(persister.persistClient).not.toHaveBeenCalled()
unsubscribe()
})
})
describe('persistQueryClientRestore', () => {
let persister: ReturnType<typeof createSpyPersister>
beforeEach(() => {
persister = createSpyPersister()
})
it('should rethrow exceptions in `restoreClient`', async () => {
const consoleMock = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
const consoleWarn = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined)
const restoreError = new Error('Error restoring client')
persister.restoreClient = () => Promise.reject(restoreError)
await expect(
persistQueryClientRestore({
queryClient,
persister,
}),
).rejects.toBe(restoreError)
expect(consoleMock).toHaveBeenCalledTimes(1)
expect(consoleWarn).toHaveBeenCalledTimes(1)
expect(consoleMock).toHaveBeenNthCalledWith(1, restoreError)
consoleMock.mockRestore()
consoleWarn.mockRestore()
})
it('should rethrow exceptions in `removeClient` before `restoreClient`', async () => {
const consoleMock = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
const consoleWarn = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined)
const restoreError = new Error('Error restoring client')
const removeError = new Error('Error removing client')
persister.restoreClient = () => Promise.reject(restoreError)
persister.removeClient = () => Promise.reject(removeError)
await expect(
persistQueryClientRestore({
queryClient,
persister,
}),
).rejects.toBe(removeError)
expect(consoleMock).toHaveBeenCalledTimes(1)
expect(consoleWarn).toHaveBeenCalledTimes(1)
expect(consoleMock).toHaveBeenNthCalledWith(1, restoreError)
consoleMock.mockRestore()
consoleWarn.mockRestore()
})
it('should rethrow error in `removeClient`', async () => {
const removeError = new Error('Error removing client')
persister.removeClient = () => Promise.reject(removeError)
persister.restoreClient = () => {
return Promise.resolve({
buster: 'random-buster',
clientState: {
mutations: [],
queries: [],
},
timestamp: new Date().getTime(),
})
}
await expect(
persistQueryClientRestore({
queryClient,
persister,
}),
).rejects.toBe(removeError)
})
it('should hydrate the query client when the persisted cache is valid', async () => {
const sourceClient = new QueryClient()
sourceClient.setQueryData(['key'], 'data')
persister.restoreClient = () =>
Promise.resolve({
buster: '',
clientState: dehydrate(sourceClient),
timestamp: Date.now(),
})
await persistQueryClientRestore({
queryClient,
persister,
})
expect(persister.removeClient).not.toHaveBeenCalled()
expect(queryClient.getQueryData(['key'])).toBe('data')
})
it('should remove the client when the persisted cache is expired', async () => {
persister.restoreClient = () =>
Promise.resolve({
buster: '',
clientState: { mutations: [], queries: [] },
timestamp: Date.now() - 1000,
})
await persistQueryClientRestore({
queryClient,
persister,
maxAge: 100,
})
expect(persister.removeClient).toHaveBeenCalledTimes(1)
})
it('should remove the client when the buster does not match', async () => {
persister.restoreClient = () =>
Promise.resolve({
buster: 'old-buster',
clientState: { mutations: [], queries: [] },
timestamp: Date.now(),
})
await persistQueryClientRestore({
queryClient,
persister,
buster: 'new-buster',
})
expect(persister.removeClient).toHaveBeenCalledTimes(1)
})
it('should remove the client when the persisted cache has no timestamp', async () => {
persister.restoreClient = () =>
Promise.resolve({
buster: '',
clientState: { mutations: [], queries: [] },
timestamp: 0,
})
await persistQueryClientRestore({
queryClient,
persister,
})
expect(persister.removeClient).toHaveBeenCalledTimes(1)
})
})
describe('persistQueryClient', () => {
it('should subscribe to the query cache after a successful restore', async () => {
const persister = createSpyPersister()
persister.restoreClient = () => Promise.resolve(undefined)
const [unsubscribe, restorePromise] = persistQueryClient({
queryClient,
persister,
})
await restorePromise
queryClient.setQueryData(['key'], 'data')
expect(persister.persistClient).toHaveBeenCalled()
unsubscribe()
})
it('should not subscribe when unsubscribed before the restore completes', async () => {
const persister = createSpyPersister()
let resolveRestore: () => void
persister.restoreClient = () =>
new Promise((resolve) => {
resolveRestore = () => resolve(undefined)
})
const [unsubscribe, restorePromise] = persistQueryClient({
queryClient,
persister,
})
// Unsubscribe before the restore resolves
unsubscribe()
resolveRestore!()
await restorePromise
queryClient.setQueryData(['key'], 'data')
expect(persister.persistClient).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { QueryClient, dehydrate } from '@tanstack/query-core'
import { removeOldestQuery } from '../retryStrategies'
import type { PersistedClient } from '../persist'
type PersistedQuery = PersistedClient['clientState']['queries'][number]
describe('removeOldestQuery', () => {
let queryClient: QueryClient
beforeEach(() => {
queryClient = new QueryClient()
})
afterEach(() => {
queryClient.clear()
})
function createQuery(
queryHash: string,
dataUpdatedAt: number,
): PersistedQuery {
queryClient.setQueryData([queryHash], 'data')
const query = dehydrate(queryClient).queries.find(
(dehydratedQuery) => dehydratedQuery.queryHash === `["${queryHash}"]`,
)!
return { ...query, state: { ...query.state, dataUpdatedAt } }
}
function createPersistedClient(
queries: Array<PersistedQuery>,
mutations: PersistedClient['clientState']['mutations'] = [],
): PersistedClient {
return {
timestamp: 0,
buster: '',
clientState: { queries, mutations },
}
}
it('should remove the query with the oldest dataUpdatedAt', () => {
const persistedClient = createPersistedClient([
createQuery('a', 30),
createQuery('b', 10),
createQuery('c', 20),
])
const result = removeOldestQuery({
persistedClient,
error: new Error('full'),
errorCount: 1,
})
expect(result?.clientState.queries.map((query) => query.queryKey)).toEqual([
['a'],
['c'],
])
})
it('should remove only a single query when multiple share the oldest dataUpdatedAt', () => {
const persistedClient = createPersistedClient([
createQuery('a', 10),
createQuery('b', 10),
])
const result = removeOldestQuery({
persistedClient,
error: new Error('full'),
errorCount: 1,
})
expect(result?.clientState.queries.map((query) => query.queryKey)).toEqual([
['b'],
])
})
it('should return undefined when there are no queries to remove', () => {
const persistedClient = createPersistedClient([])
const result = removeOldestQuery({
persistedClient,
error: new Error('full'),
errorCount: 1,
})
expect(result).toBeUndefined()
})
it('should preserve mutations when removing the oldest query', () => {
queryClient.getMutationCache().build(queryClient, {
mutationFn: () => Promise.resolve('data'),
})
const { mutations } = dehydrate(queryClient, {
shouldDehydrateMutation: () => true,
})
const persistedClient = createPersistedClient(
[createQuery('a', 10)],
mutations,
)
const result = removeOldestQuery({
persistedClient,
error: new Error('full'),
errorCount: 1,
})
expect(result?.clientState.queries).toEqual([])
expect(result?.clientState.mutations).toEqual(mutations)
})
})

View File

@@ -0,0 +1,28 @@
import { vi } from 'vitest'
import { sleep } from '@tanstack/query-test-utils'
import type { PersistedClient, Persister } from '../persist'
export function createMockPersister(): Persister {
let storedState: PersistedClient | undefined
return {
persistClient(persistClient: PersistedClient) {
storedState = persistClient
},
async restoreClient() {
await sleep(10)
return storedState
},
removeClient() {
storedState = undefined
},
}
}
export function createSpyPersister(): Persister {
return {
persistClient: vi.fn(),
restoreClient: vi.fn(),
removeClient: vi.fn(),
}
}

View File

@@ -0,0 +1,372 @@
import {
hashKey,
matchQuery,
notifyManager,
partialMatchKey,
} from '@tanstack/query-core'
import type {
Query,
QueryClient,
QueryFilters,
QueryFunctionContext,
QueryKey,
QueryState,
} from '@tanstack/query-core'
export interface PersistedQuery {
buster: string
queryHash: string
queryKey: QueryKey
state: QueryState
}
export type MaybePromise<T> = T | Promise<T>
export interface AsyncStorage<TStorageValue = string> {
getItem: (key: string) => MaybePromise<TStorageValue | undefined | null>
setItem: (key: string, value: TStorageValue) => MaybePromise<unknown>
removeItem: (key: string) => MaybePromise<void>
entries?: () => MaybePromise<Array<[key: string, value: TStorageValue]>>
}
export interface StoragePersisterOptions<TStorageValue = string> {
/** The storage client used for setting and retrieving items from cache.
* For SSR pass in `undefined`.
*/
storage: AsyncStorage<TStorageValue> | undefined | null
/**
* How to serialize the data to storage.
* @default `JSON.stringify`
*/
serialize?: (persistedQuery: PersistedQuery) => MaybePromise<TStorageValue>
/**
* How to deserialize the data from storage.
* @default `JSON.parse`
*/
deserialize?: (cachedString: TStorageValue) => MaybePromise<PersistedQuery>
/**
* A unique string that can be used to forcefully invalidate existing caches,
* if they do not share the same buster string
*/
buster?: string
/**
* The max-allowed age of the cache in milliseconds.
* If a persisted cache is found that is older than this
* time, it will be discarded
* @default 24 hours
*/
maxAge?: number
/**
* Prefix to be used for storage key.
* Storage key is a combination of prefix and query hash in a form of `prefix-queryHash`.
* @default 'tanstack-query'
*/
prefix?: string
/**
* If set to `true`, the query will refetch on successful query restoration if the data is stale.
* If set to `false`, the query will not refetch on successful query restoration.
* If set to `'always'`, the query will always refetch on successful query restoration.
* Defaults to `true`.
*/
refetchOnRestore?: boolean | 'always'
/**
* Filters to narrow down which Queries should be persisted.
*/
filters?: QueryFilters
}
export const PERSISTER_KEY_PREFIX = 'tanstack-query'
/**
* Warning: experimental feature.
* This utility function enables fine-grained query persistence.
* Simple add it as a `persister` parameter to `useQuery` or `defaultOptions` on `queryClient`.
*
* ```
* useQuery({
queryKey: ['myKey'],
queryFn: fetcher,
persister: createPersister({
storage: localStorage,
}),
})
```
*/
export function experimental_createQueryPersister<TStorageValue = string>({
storage,
buster = '',
maxAge = 1000 * 60 * 60 * 24,
serialize = JSON.stringify as Required<
StoragePersisterOptions<TStorageValue>
>['serialize'],
deserialize = JSON.parse as Required<
StoragePersisterOptions<TStorageValue>
>['deserialize'],
prefix = PERSISTER_KEY_PREFIX,
refetchOnRestore = true,
filters,
}: StoragePersisterOptions<TStorageValue>) {
function isExpiredOrBusted(persistedQuery: PersistedQuery) {
if (persistedQuery.state.dataUpdatedAt) {
const queryAge = Date.now() - persistedQuery.state.dataUpdatedAt
const expired = queryAge > maxAge
const busted = persistedQuery.buster !== buster
if (expired || busted) {
return true
}
return false
}
return true
}
async function retrieveQuery<T>(
queryHash: string,
afterRestoreMacroTask?: (persistedQuery: PersistedQuery) => void,
) {
if (storage != null) {
const storageKey = `${prefix}-${queryHash}`
try {
const storedData = await storage.getItem(storageKey)
if (storedData) {
let persistedQuery: PersistedQuery
try {
persistedQuery = await deserialize(storedData)
} catch {
await storage.removeItem(storageKey)
return
}
if (isExpiredOrBusted(persistedQuery)) {
await storage.removeItem(storageKey)
} else {
if (afterRestoreMacroTask) {
// Just after restoring we want to get fresh data from the server if it's stale
notifyManager.schedule(() =>
afterRestoreMacroTask(persistedQuery),
)
}
// We must resolve the promise here, as otherwise we will have `loading` state in the app until `queryFn` resolves
return persistedQuery.state.data as T
}
}
} catch (err) {
if (process.env.NODE_ENV === 'development') {
console.error(err)
console.warn(
'Encountered an error attempting to restore query cache from persisted location.',
)
}
await storage.removeItem(storageKey)
}
}
return
}
async function persistQueryByKey(
queryKey: QueryKey,
queryClient: QueryClient,
) {
if (storage != null) {
const query = queryClient.getQueryCache().find({ queryKey })
if (query) {
await persistQuery(query)
} else {
if (process.env.NODE_ENV === 'development') {
console.warn(
'Could not find query to be persisted. QueryKey:',
JSON.stringify(queryKey),
)
}
}
}
}
async function persistQuery(query: Query) {
if (storage != null) {
const storageKey = `${prefix}-${query.queryHash}`
storage.setItem(
storageKey,
await serialize({
state: query.state,
queryKey: query.queryKey,
queryHash: query.queryHash,
buster: buster,
}),
)
}
}
async function persisterFn<T, TQueryKey extends QueryKey>(
queryFn: (context: QueryFunctionContext<TQueryKey>) => T | Promise<T>,
ctx: QueryFunctionContext<TQueryKey>,
query: Query,
) {
const matchesFilter = filters ? matchQuery(filters, query) : true
// Try to restore only if we do not have any data in the cache and we have persister defined
if (matchesFilter && query.state.data === undefined && storage != null) {
const restoredData = await retrieveQuery(
query.queryHash,
(persistedQuery: PersistedQuery) => {
// Set proper updatedAt, since resolving in the first pass overrides those values
query.setState({
dataUpdatedAt: persistedQuery.state.dataUpdatedAt,
errorUpdatedAt: persistedQuery.state.errorUpdatedAt,
})
if (
refetchOnRestore === 'always' ||
(refetchOnRestore === true && query.isStale())
) {
query.fetch()
}
},
)
if (restoredData !== undefined) {
return Promise.resolve(restoredData as T)
}
}
// If we did not restore, or restoration failed - fetch
const queryFnResult = await queryFn(ctx)
if (matchesFilter && storage != null) {
// Persist if we have storage defined, we use timeout to get proper state to be persisted
notifyManager.schedule(() => {
persistQuery(query)
})
}
return Promise.resolve(queryFnResult)
}
async function persisterGc() {
if (storage?.entries) {
const storageKeyPrefix = `${prefix}-`
const entries = await storage.entries()
for (const [key, value] of entries) {
if (key.startsWith(storageKeyPrefix)) {
let persistedQuery: PersistedQuery
try {
persistedQuery = await deserialize(value)
} catch {
await storage.removeItem(key)
continue
}
if (isExpiredOrBusted(persistedQuery)) {
await storage.removeItem(key)
}
}
}
} else if (process.env.NODE_ENV === 'development') {
throw new Error(
'Provided storage does not implement `entries` method. Garbage collection is not possible without ability to iterate over storage items.',
)
}
}
async function restoreQueries(
queryClient: QueryClient,
filters: Pick<QueryFilters, 'queryKey' | 'exact'> = {},
): Promise<void> {
const { exact, queryKey } = filters
if (storage?.entries) {
const storageKeyPrefix = `${prefix}-`
const entries = await storage.entries()
for (const [key, value] of entries) {
if (key.startsWith(storageKeyPrefix)) {
let persistedQuery: PersistedQuery
try {
persistedQuery = await deserialize(value)
} catch {
await storage.removeItem(key)
continue
}
if (isExpiredOrBusted(persistedQuery)) {
await storage.removeItem(key)
continue
}
if (queryKey) {
if (exact) {
if (persistedQuery.queryHash !== hashKey(queryKey)) {
continue
}
} else if (!partialMatchKey(persistedQuery.queryKey, queryKey)) {
continue
}
}
queryClient.setQueryData(
persistedQuery.queryKey,
persistedQuery.state.data,
{
updatedAt: persistedQuery.state.dataUpdatedAt,
},
)
}
}
} else if (process.env.NODE_ENV === 'development') {
throw new Error(
'Provided storage does not implement `entries` method. Restoration of all stored entries is not possible without ability to iterate over storage items.',
)
}
}
async function removeQueries(
filters: Pick<QueryFilters, 'queryKey' | 'exact'> = {},
): Promise<void> {
const { exact, queryKey } = filters
if (storage?.entries) {
const entries = await storage.entries()
const storageKeyPrefix = `${prefix}-`
for (const [key, value] of entries) {
if (key.startsWith(storageKeyPrefix)) {
if (!queryKey) {
await storage.removeItem(key)
continue
}
let persistedQuery: PersistedQuery
try {
persistedQuery = await deserialize(value)
} catch {
await storage.removeItem(key)
continue
}
if (exact) {
if (persistedQuery.queryHash !== hashKey(queryKey)) {
continue
}
} else if (!partialMatchKey(persistedQuery.queryKey, queryKey)) {
continue
}
await storage.removeItem(key)
}
}
} else if (process.env.NODE_ENV === 'development') {
throw new Error(
'Provided storage does not implement `entries` method. Removal of stored entries is not possible without ability to iterate over storage items.',
)
}
}
return {
persisterFn,
persistQuery,
persistQueryByKey,
retrieveQuery,
persisterGc,
restoreQueries,
removeQueries,
}
}

View File

@@ -0,0 +1,5 @@
/* istanbul ignore file */
export * from './persist'
export * from './retryStrategies'
export * from './createPersister'

View File

@@ -0,0 +1,179 @@
import { dehydrate, hydrate } from '@tanstack/query-core'
import type {
DehydrateOptions,
DehydratedState,
HydrateOptions,
NotifyEventType,
QueryClient,
} from '@tanstack/query-core'
export type Promisable<T> = T | PromiseLike<T>
export interface Persister {
persistClient: (persistClient: PersistedClient) => Promisable<void>
restoreClient: () => Promisable<PersistedClient | undefined>
removeClient: () => Promisable<void>
}
export interface PersistedClient {
timestamp: number
buster: string
clientState: DehydratedState
}
export interface PersistQueryClientRootOptions {
/** The QueryClient to persist */
queryClient: QueryClient
/** The Persister interface for storing and restoring the cache
* to/from a persisted location */
persister: Persister
/** A unique string that can be used to forcefully
* invalidate existing caches if they do not share the same buster string */
buster?: string
}
export interface PersistedQueryClientRestoreOptions extends PersistQueryClientRootOptions {
/** The max-allowed age of the cache in milliseconds.
* If a persisted cache is found that is older than this
* time, it will be discarded */
maxAge?: number
/** The options passed to the hydrate function */
hydrateOptions?: HydrateOptions
}
export interface PersistedQueryClientSaveOptions extends PersistQueryClientRootOptions {
/** The options passed to the dehydrate function */
dehydrateOptions?: DehydrateOptions
}
export interface PersistQueryClientOptions
extends
PersistedQueryClientRestoreOptions,
PersistedQueryClientSaveOptions,
PersistQueryClientRootOptions {}
/**
* Checks if emitted event is about cache change and not about observers.
* Useful for persist, where we only want to trigger save when cache is changed.
*/
const cacheEventTypes: Array<NotifyEventType> = ['added', 'removed', 'updated']
function isCacheEventType(eventType: NotifyEventType) {
return cacheEventTypes.includes(eventType)
}
/**
* Restores persisted data to the QueryCache
* - data obtained from persister.restoreClient
* - data is hydrated using hydrateOptions
* If data is expired, busted, empty, or throws, it runs persister.removeClient
*/
export async function persistQueryClientRestore({
queryClient,
persister,
maxAge = 1000 * 60 * 60 * 24,
buster = '',
hydrateOptions,
}: PersistedQueryClientRestoreOptions) {
try {
const persistedClient = await persister.restoreClient()
if (persistedClient) {
if (persistedClient.timestamp) {
const expired = Date.now() - persistedClient.timestamp > maxAge
const busted = persistedClient.buster !== buster
if (expired || busted) {
return persister.removeClient()
} else {
hydrate(queryClient, persistedClient.clientState, hydrateOptions)
}
} else {
return persister.removeClient()
}
}
} catch (err) {
if (process.env.NODE_ENV !== 'production') {
console.error(err)
console.warn(
'Encountered an error attempting to restore client cache from persisted location. As a precaution, the persisted cache will be discarded.',
)
}
await persister.removeClient()
throw err
}
}
/**
* Persists data from the QueryCache
* - data dehydrated using dehydrateOptions
* - data is persisted using persister.persistClient
*/
export async function persistQueryClientSave({
queryClient,
persister,
buster = '',
dehydrateOptions,
}: PersistedQueryClientSaveOptions) {
const persistClient: PersistedClient = {
buster,
timestamp: Date.now(),
clientState: dehydrate(queryClient, dehydrateOptions),
}
await persister.persistClient(persistClient)
}
/**
* Subscribe to QueryCache and MutationCache updates (for persisting)
* @returns an unsubscribe function (to discontinue monitoring)
*/
export function persistQueryClientSubscribe(
props: PersistedQueryClientSaveOptions,
) {
const unsubscribeQueryCache = props.queryClient
.getQueryCache()
.subscribe((event) => {
if (isCacheEventType(event.type)) {
persistQueryClientSave(props)
}
})
const unsubscribeMutationCache = props.queryClient
.getMutationCache()
.subscribe((event) => {
if (isCacheEventType(event.type)) {
persistQueryClientSave(props)
}
})
return () => {
unsubscribeQueryCache()
unsubscribeMutationCache()
}
}
/**
* Restores persisted data to QueryCache and persists further changes.
*/
export function persistQueryClient(
props: PersistQueryClientOptions,
): [() => void, Promise<void>] {
let hasUnsubscribed = false
let persistQueryClientUnsubscribe: (() => void) | undefined
const unsubscribe = () => {
hasUnsubscribed = true
persistQueryClientUnsubscribe?.()
}
// Attempt restore
const restorePromise = persistQueryClientRestore(props).then(() => {
if (!hasUnsubscribed) {
// Subscribe to changes in the query cache to trigger the save
persistQueryClientUnsubscribe = persistQueryClientSubscribe(props)
}
})
return [unsubscribe, restorePromise]
}

View File

@@ -0,0 +1,30 @@
import type { PersistedClient } from './persist'
export type PersistRetryer = (props: {
persistedClient: PersistedClient
error: Error
errorCount: number
}) => PersistedClient | undefined
export const removeOldestQuery: PersistRetryer = ({ persistedClient }) => {
const mutations = [...persistedClient.clientState.mutations]
const queries = [...persistedClient.clientState.queries]
const client: PersistedClient = {
...persistedClient,
clientState: { mutations, queries },
}
// sort queries by dataUpdatedAt (oldest first)
const sortedQueries = [...queries].sort(
(a, b) => a.state.dataUpdatedAt - b.state.dataUpdatedAt,
)
// clean oldest query
if (sortedQueries.length > 0) {
const oldestData = sortedQueries.shift()
client.clientState.queries = queries.filter((q) => q !== oldestData)
return client
}
return undefined
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist-ts",
"rootDir": "."
},
"include": ["src", "*.config.ts", "*.config.js", "package.json"],
"references": [{ "path": "../query-core" }]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"incremental": false,
"composite": false,
"rootDir": "../../"
},
"include": ["src"],
"exclude": ["src/__tests__"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'tsdown'
import { legacyConfig, modernConfig } from './root.tsdown.config.js'
export default defineConfig([
modernConfig({ entry: ['src/*.ts'] }),
legacyConfig({ entry: ['src/*.ts'] }),
])

View File

@@ -0,0 +1,30 @@
import { defineConfig } from 'vitest/config'
import packageJson from './package.json'
export default defineConfig({
// fix from https://github.com/vitest-dev/vitest/issues/6992#issuecomment-2509408660
resolve: {
conditions: ['@tanstack/custom-condition'],
},
environments: {
ssr: {
resolve: {
conditions: ['@tanstack/custom-condition'],
},
},
},
test: {
name: packageJson.name,
dir: './src',
watch: false,
coverage: {
enabled: !!process.env.CI,
provider: 'istanbul',
include: ['src/**/*'],
exclude: ['src/__tests__/**'],
},
typecheck: { enabled: true },
restoreMocks: true,
},
})