Skip to content

Commit 34afdde

Browse files
authored
feat(persistQueryClient): persist error handling (TanStack#3556)
* refactor: remove type-fest as a dependency only used for the Promisable type, which is easy to recreate * feat(persistQueryClient): error handling strategies for persist plugins * feat(persistQueryClient): error handling strategies for persist plugins adapt tests * make handlePersistError return null to stop retries if null is returned, which is also the default strategy, the webstorage entry will be removed completely. * test for default behaviour * async version for persist error handling to make sync and async compatible, persist version must also throw an error to abort * make sure that async persister can accept sync error handlers * undefined errorStrategy, or return undefined from it, will just not persist anymore * rename to retry + documentation * improve docs
1 parent 1909cb9 commit 34afdde

File tree

10 files changed

+167
-93
lines changed

10 files changed

+167
-93
lines changed

docs/src/pages/plugins/createAsyncStoragePersister.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ persistQueryClient({
3737
})
3838
```
3939

40+
## Retries
41+
42+
Retries work the same as for a [WebStoragePersister](./createWebStoragePersister), except that they can also be asynchronous. You can also use all the predefined retry handlers.
43+
4044
## API
4145

4246
### `createAsyncStoragePersister`
@@ -62,6 +66,8 @@ interface CreateAsyncStoragePersisterOptions {
6266
serialize?: (client: PersistedClient) => string
6367
/** How to deserialize the data from storage */
6468
deserialize?: (cachedString: string) => PersistedClient
69+
/** How to retry persistence on error **/
70+
retry?: AsyncPersistRetryer
6571
}
6672

6773
interface AsyncStorage {

docs/src/pages/plugins/createWebStoragePersister.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,34 @@ persistQueryClient({
3434
})
3535
```
3636

37+
## Retries
38+
39+
Persistence can fail, e.g. if the size exceeds the available space on the storage. Errors can be handled gracefully by providing a `retry` function to the persister.
40+
41+
The retry function receives the `persistedClient` it tried to save, as well as the `error` and the `errorCount` as input. It is expected to return a _new_ `PersistedClient`, with which it tries to persist again. If _undefined_ is returned, there will be no further attempt to persist.
42+
43+
```ts
44+
export type PersistRetryer = (props: {
45+
persistedClient: PersistedClient
46+
error: Error
47+
errorCount: number
48+
}) => PersistedClient | undefined
49+
```
50+
51+
### Predefined strategies
52+
53+
Per default, no retry will occur. You can use one of the predefined strategies to handle retries. They can be imported `from 'react-query/persistQueryClient'`:
54+
55+
- `removeOldestQuery`
56+
- will return a new `PersistedClient` with the oldest query removed.
57+
58+
```js
59+
const localStoragePersister = createWebStoragePersister({
60+
storage: window.localStorage,
61+
retry: removeOldestQuery
62+
})
63+
```
64+
3765
## API
3866

3967
### `createWebStoragePersister`
@@ -59,6 +87,8 @@ interface CreateWebStoragePersisterOptions {
5987
serialize?: (client: PersistedClient) => string
6088
/** How to deserialize the data from storage */
6189
deserialize?: (cachedString: string) => PersistedClient
90+
/** How to retry persistence on error **/
91+
retry?: PersistRetryer
6292
}
6393
```
6494

@@ -74,7 +104,7 @@ The default options are:
74104
```
75105

76106
#### `serialize` and `deserialize` options
77-
There is a limit to the amount of data which can be stored in `localStorage`.
107+
There is a limit to the amount of data which can be stored in `localStorage`.
78108
If you need to store more data in `localStorage`, you can override the `serialize` and `deserialize` functions to compress and decrompress the data using a library like [lz-string](https://github.com/pieroxy/lz-string/).
79109

80110
```js

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,6 @@
181181
"rollup-plugin-size": "^0.2.2",
182182
"rollup-plugin-terser": "^7.0.2",
183183
"rollup-plugin-visualizer": "^5.6.0",
184-
"type-fest": "^0.21.0",
185184
"typescript": "4.5.3"
186185
},
187186
"bundlewatch": {
Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
1-
import { PersistedClient, Persister } from '../persistQueryClient'
1+
import { PersistedClient, Persister, Promisable } from '../persistQueryClient'
22
import { asyncThrottle } from './asyncThrottle'
3+
import { noop } from '../core/utils'
34

45
interface AsyncStorage {
56
getItem: (key: string) => Promise<string | null>
67
setItem: (key: string, value: string) => Promise<void>
78
removeItem: (key: string) => Promise<void>
89
}
910

11+
export type AsyncPersistRetryer = (props: {
12+
persistedClient: PersistedClient
13+
error: Error
14+
errorCount: number
15+
}) => Promisable<PersistedClient | undefined>
16+
1017
interface CreateAsyncStoragePersisterOptions {
1118
/** The storage client used for setting an retrieving items from cache */
12-
storage: AsyncStorage
19+
storage: AsyncStorage | undefined
1320
/** The key to use when storing the cache */
1421
key?: string
1522
/** To avoid spamming,
@@ -25,6 +32,8 @@ interface CreateAsyncStoragePersisterOptions {
2532
* @default `JSON.parse`
2633
*/
2734
deserialize?: (cachedString: string) => PersistedClient
35+
36+
retry?: AsyncPersistRetryer
2837
}
2938

3039
export const createAsyncStoragePersister = ({
@@ -33,21 +42,56 @@ export const createAsyncStoragePersister = ({
3342
throttleTime = 1000,
3443
serialize = JSON.stringify,
3544
deserialize = JSON.parse,
45+
retry,
3646
}: CreateAsyncStoragePersisterOptions): Persister => {
37-
return {
38-
persistClient: asyncThrottle(
39-
persistedClient => storage.setItem(key, serialize(persistedClient)),
40-
{ interval: throttleTime }
41-
),
42-
restoreClient: async () => {
43-
const cacheString = await storage.getItem(key)
44-
45-
if (!cacheString) {
46-
return
47+
if (typeof storage !== 'undefined') {
48+
const trySave = async (
49+
persistedClient: PersistedClient
50+
): Promise<Error | undefined> => {
51+
try {
52+
await storage.setItem(key, serialize(persistedClient))
53+
} catch (error) {
54+
return error as Error
4755
}
56+
}
57+
58+
return {
59+
persistClient: asyncThrottle(
60+
async persistedClient => {
61+
let client: PersistedClient | undefined = persistedClient
62+
let error = await trySave(client)
63+
let errorCount = 0
64+
while (error && client) {
65+
errorCount++
66+
client = await retry?.({
67+
persistedClient: client,
68+
error,
69+
errorCount,
70+
})
71+
72+
if (client) {
73+
error = await trySave(client)
74+
}
75+
}
76+
},
77+
{ interval: throttleTime }
78+
),
79+
restoreClient: async () => {
80+
const cacheString = await storage.getItem(key)
4881

49-
return deserialize(cacheString) as PersistedClient
50-
},
51-
removeClient: () => storage.removeItem(key),
82+
if (!cacheString) {
83+
return
84+
}
85+
86+
return deserialize(cacheString) as PersistedClient
87+
},
88+
removeClient: () => storage.removeItem(key),
89+
}
90+
}
91+
92+
return {
93+
persistClient: noop,
94+
restoreClient: noop,
95+
removeClient: noop,
5296
}
5397
}

src/createWebStoragePersister/index.ts

Lines changed: 29 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { noop } from '../core/utils'
2-
import { PersistedClient, Persister } from '../persistQueryClient'
2+
import {
3+
PersistedClient,
4+
Persister,
5+
PersistRetryer,
6+
} from '../persistQueryClient'
37

48
interface CreateWebStoragePersisterOptions {
5-
/** The storage client used for setting an retrieving items from cache */
6-
storage: Storage
9+
/** The storage client used for setting and retrieving items from cache */
10+
storage: Storage | undefined
711
/** The key to use when storing the cache */
812
key?: string
913
/** To avoid spamming,
@@ -19,6 +23,8 @@ interface CreateWebStoragePersisterOptions {
1923
* @default `JSON.parse`
2024
*/
2125
deserialize?: (cachedString: string) => PersistedClient
26+
27+
retry?: PersistRetryer
2228
}
2329

2430
export function createWebStoragePersister({
@@ -27,46 +33,31 @@ export function createWebStoragePersister({
2733
throttleTime = 1000,
2834
serialize = JSON.stringify,
2935
deserialize = JSON.parse,
36+
retry,
3037
}: CreateWebStoragePersisterOptions): Persister {
31-
//try to save data to storage
32-
function trySave(persistedClient: PersistedClient) {
33-
try {
34-
storage.setItem(key, serialize(persistedClient))
35-
} catch {
36-
return false
37-
}
38-
return true
39-
}
40-
4138
if (typeof storage !== 'undefined') {
39+
const trySave = (persistedClient: PersistedClient): Error | undefined => {
40+
try {
41+
storage.setItem(key, serialize(persistedClient))
42+
} catch (error) {
43+
return error as Error
44+
}
45+
}
4246
return {
4347
persistClient: throttle(persistedClient => {
44-
if (trySave(persistedClient) !== true) {
45-
const mutations = [...persistedClient.clientState.mutations]
46-
const queries = [...persistedClient.clientState.queries]
47-
const client: PersistedClient = {
48-
...persistedClient,
49-
clientState: { mutations, queries },
50-
}
51-
52-
// sort queries by dataUpdatedAt (oldest first)
53-
const sortedQueries = [...queries].sort(
54-
(a, b) => a.state.dataUpdatedAt - b.state.dataUpdatedAt
55-
)
56-
// clean old queries and try to save
57-
while (sortedQueries.length > 0) {
58-
const oldestData = sortedQueries.shift()
59-
client.clientState.queries = queries.filter(q => q !== oldestData)
60-
if (trySave(client)) {
61-
return // save success
62-
}
63-
}
48+
let client: PersistedClient | undefined = persistedClient
49+
let error = trySave(client)
50+
let errorCount = 0
51+
while (error && client) {
52+
errorCount++
53+
client = retry?.({
54+
persistedClient: client,
55+
error,
56+
errorCount,
57+
})
6458

65-
// clean mutations and try to save
66-
while (mutations.shift()) {
67-
if (trySave(client)) {
68-
return // save success
69-
}
59+
if (client) {
60+
error = trySave(client)
7061
}
7162
}
7263
}, throttleTime),

src/createWebStoragePersister/tests/storageIsFull.test.ts

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { dehydrate, MutationCache, QueryCache, QueryClient } from '../../core'
22
import { createWebStoragePersister } from '../index'
3+
import { removeOldestQuery } from '../../persistQueryClient'
34
import { sleep } from '../../tests/utils'
45

56
function getMockStorage(limitSize?: number) {
@@ -11,7 +12,7 @@ function getMockStorage(limitSize?: number) {
1112
},
1213

1314
setItem(key: string, value: string) {
14-
if (limitSize) {
15+
if (typeof limitSize !== 'undefined') {
1516
const currentSize = Array.from(dataSet.entries()).reduce(
1617
(n, d) => d[0].length + d[1].length + n,
1718
0
@@ -21,7 +22,7 @@ function getMockStorage(limitSize?: number) {
2122
limitSize
2223
) {
2324
throw Error(
24-
` Failed to execute 'setItem' on 'Storage': Setting the value of '${key}' exceeded the quota.`
25+
`Failed to execute 'setItem' on 'Storage': Setting the value of '${key}' exceeded the quota.`
2526
)
2627
}
2728
}
@@ -74,6 +75,7 @@ describe('createWebStoragePersister ', () => {
7475
const webStoragePersister = createWebStoragePersister({
7576
throttleTime: 0,
7677
storage,
78+
retry: removeOldestQuery,
7779
})
7880

7981
await queryClient.prefetchQuery(['A'], () => Promise.resolve('A'.repeat(N)))
@@ -121,55 +123,30 @@ describe('createWebStoragePersister ', () => {
121123
restoredClient2?.clientState.queries.find(q => q.queryKey[0] === 'B')
122124
).toBeUndefined()
123125
})
124-
125-
test('should clean queries before mutations when storage full', async () => {
126+
test('should clear storage as default error handling', async () => {
126127
const queryCache = new QueryCache()
127128
const mutationCache = new MutationCache()
128129
const queryClient = new QueryClient({ queryCache, mutationCache })
129130

130131
const N = 2000
131-
const storage = getMockStorage(N * 5) // can save 4 items;
132+
const storage = getMockStorage(0)
132133
const webStoragePersister = createWebStoragePersister({
133134
throttleTime: 0,
134135
storage,
136+
retry: removeOldestQuery,
135137
})
136138

137-
mutationCache.build(
138-
queryClient,
139-
{
140-
mutationKey: ['MUTATIONS'],
141-
mutationFn: () => Promise.resolve('M'.repeat(N)),
142-
},
143-
{
144-
error: null,
145-
context: '',
146-
failureCount: 1,
147-
isPaused: true,
148-
status: 'success',
149-
variables: '',
150-
data: 'M'.repeat(N),
151-
}
152-
)
153-
await sleep(1)
154139
await queryClient.prefetchQuery(['A'], () => Promise.resolve('A'.repeat(N)))
155140
await sleep(1)
156-
await queryClient.prefetchQuery(['B'], () => Promise.resolve('B'.repeat(N)))
157-
await queryClient.prefetchQuery(['C'], () => Promise.resolve('C'.repeat(N)))
158-
await sleep(1)
159-
await queryClient.prefetchQuery(['D'], () => Promise.resolve('D'.repeat(N)))
160141

161142
const persistClient = {
162-
buster: 'test-limit-mutations',
143+
buster: 'test-limit',
163144
timestamp: Date.now(),
164145
clientState: dehydrate(queryClient),
165146
}
166147
webStoragePersister.persistClient(persistClient)
167148
await sleep(10)
168149
const restoredClient = await webStoragePersister.restoreClient()
169-
expect(restoredClient?.clientState.mutations.length).toEqual(1)
170-
expect(restoredClient?.clientState.queries.length).toEqual(3)
171-
expect(
172-
restoredClient?.clientState.queries.find(q => q.queryKey === ['A'])
173-
).toBeUndefined()
150+
expect(restoredClient).toEqual(undefined)
174151
})
175152
})

src/persistQueryClient/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './persist'
22
export * from './PersistQueryClientProvider'
3+
export * from './retryStrategies'

src/persistQueryClient/persist.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
HydrateOptions,
77
hydrate,
88
} from '../core'
9-
import { Promisable } from 'type-fest'
9+
10+
export type Promisable<T> = T | PromiseLike<T>
1011

1112
export interface Persister {
1213
persistClient(persistClient: PersistedClient): Promisable<void>

0 commit comments

Comments
 (0)