Skip to content

Commit 5cd86c6

Browse files
fix(query-persist-client-core): add option to change behavior of refetch after restore (#9745)
1 parent f4a0cd5 commit 5cd86c6

File tree

5 files changed

+153
-6
lines changed

5 files changed

+153
-6
lines changed

.changeset/pretty-geese-scream.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/query-persist-client-core': minor
3+
---
4+
5+
Added a refetchOnRestore setting to control refetching after restoring persisted queries

docs/framework/react/plugins/createPersister.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,13 @@ export interface StoragePersisterOptions {
169169
* Storage key is a combination of prefix and query hash in a form of `prefix-queryHash`.
170170
*/
171171
prefix?: string
172+
/**
173+
* If set to `true`, the query will refetch on successful query restoration if the data is stale.
174+
* If set to `false`, the query will not refetch on successful query restoration.
175+
* If set to `'always'`, the query will always refetch on successful query restoration.
176+
* Defaults to `true`.
177+
*/
178+
refetchOnRestore?: boolean | 'always'
172179
/**
173180
* Filters to narrow down which Queries should be persisted.
174181
*/
@@ -191,5 +198,6 @@ The default options are:
191198
maxAge = 1000 * 60 * 60 * 24,
192199
serialize = JSON.stringify,
193200
deserialize = JSON.parse,
201+
refetchOnRestore = true,
194202
}
195203
```

docs/framework/vue/plugins/createPersister.md

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,11 @@ bun add @tanstack/query-persist-client-core
3333

3434
- Import the `experimental_createQueryPersister` function
3535
- Create a new `experimental_createQueryPersister`
36-
- you can pass any `storage` to it that adheres to the `AsyncStorage` or `Storage` interface
36+
- you can pass any `storage` to it that adheres to the `AsyncStorage` interface
3737
- Pass that `persister` as an option to your Query. This can be done either by passing it to the `defaultOptions` of the `QueryClient` or to any `useQuery` hook instance.
3838
- If you pass this `persister` as `defaultOptions`, all queries will be persisted to the provided `storage`. You can additionally narrow this down by passing `filters`. In contrast to the `persistClient` plugin, this will not persist the whole query client as a single item, but each query separately. As a key, the query hash is used.
3939
- If you provide this `persister` to a single `useQuery` hook, only this Query will be persisted.
40+
- Note: `queryClient.setQueryData()` operations are not persisted, this means that if you perform an optimistic update and refresh the page before the query has been invalidated, your changes to the query data will be lost. See https://github.com/TanStack/query/issues/6310
4041

4142
This way, you do not need to store whole `QueryClient`, but choose what is worth to be persisted in your application. Each query is lazily restored (when the Query is first used) and persisted (after each run of the `queryFn`), so it does not need to be throttled. `staleTime` is also respected after restoring the Query, so if data is considered `stale`, it will be refetched immediately after restoring. If data is `fresh`, the `queryFn` will not run.
4243

@@ -65,6 +66,63 @@ const queryClient = new QueryClient({
6566

6667
The `createPersister` plugin technically wraps the `queryFn`, so it doesn't restore if the `queryFn` doesn't run. In that way, it acts as a caching layer between the Query and the network. Thus, the `networkMode` defaults to `'offlineFirst'` when a persister is used, so that restoring from the persistent storage can also happen even if there is no network connection.
6768

69+
## Additional utilities
70+
71+
Invoking `experimental_createQueryPersister` returns additional utilities in addition to `persisterFn` for easier implementation of userland functionalities.
72+
73+
### `persistQueryByKey(queryKey: QueryKey, queryClient: QueryClient): Promise<void>`
74+
75+
This function will persist `Query` to storage and key defined when creating persister.
76+
This utility might be used along `setQueryData` to persist optimistic update to storage without waiting for invalidation.
77+
78+
```tsx
79+
const persister = experimental_createQueryPersister({
80+
storage: AsyncStorage,
81+
maxAge: 1000 * 60 * 60 * 12, // 12 hours
82+
})
83+
84+
const queryClient = useQueryClient()
85+
86+
useMutation({
87+
mutationFn: updateTodo,
88+
onMutate: async (newTodo) => {
89+
...
90+
// Optimistically update to the new value
91+
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
92+
// And persist it to storage
93+
persister.persistQueryByKey(['todos'], queryClient)
94+
...
95+
},
96+
})
97+
```
98+
99+
### `retrieveQuery<T>(queryHash: string): Promise<T | undefined>`
100+
101+
This function would attempt to retrieve persisted query by `queryHash`.
102+
If `query` is `expired`, `busted` or `malformed` it would be removed from the storage instead, and `undefined` would be returned.
103+
104+
### `persisterGc(): Promise<void>`
105+
106+
This function can be used to sporadically clean up stoage from `expired`, `busted` or `malformed` entries.
107+
108+
For this function to work, your storage must expose `entries` method that would return a `key-value tuple array`.
109+
For example `Object.entries(localStorage)` for `localStorage` or `entries` from `idb-keyval`.
110+
111+
### `restoreQueries(queryClient: QueryClient, filters): Promise<void>`
112+
113+
This function can be used to restore queries that are currently stored by persister.
114+
For example when your app is starting up in offline mode, or you want all or only specific data from previous session to be immediately available without intermediate `loading` state.
115+
116+
The filter object supports the following properties:
117+
118+
- `queryKey?: QueryKey`
119+
- Set this property to define a query key to match on.
120+
- `exact?: boolean`
121+
- If you don't want to search queries inclusively by query key, you can pass the `exact: true` option to return only the query with the exact query key you have passed.
122+
123+
For this function to work, your storage must expose `entries` method that would return a `key-value tuple array`.
124+
For example `Object.entries(localStorage)` for `localStorage` or `entries` from `idb-keyval`.
125+
68126
## API
69127

70128
### `experimental_createQueryPersister`
@@ -108,16 +166,24 @@ export interface StoragePersisterOptions {
108166
* Storage key is a combination of prefix and query hash in a form of `prefix-queryHash`.
109167
*/
110168
prefix?: string
169+
/**
170+
* If set to `true`, the query will refetch on successful query restoration if the data is stale.
171+
* If set to `false`, the query will not refetch on successful query restoration.
172+
* If set to `'always'`, the query will always refetch on successful query restoration.
173+
* Defaults to `true`.
174+
*/
175+
refetchOnRestore?: boolean | 'always'
111176
/**
112177
* Filters to narrow down which Queries should be persisted.
113178
*/
114179
filters?: QueryFilters
115180
}
116181

117-
interface AsyncStorage {
118-
getItem: (key: string) => Promise<string | undefined | null>
119-
setItem: (key: string, value: string) => Promise<unknown>
120-
removeItem: (key: string) => Promise<void>
182+
interface AsyncStorage<TStorageValue = string> {
183+
getItem: (key: string) => MaybePromise<TStorageValue | undefined | null>
184+
setItem: (key: string, value: TStorageValue) => MaybePromise<unknown>
185+
removeItem: (key: string) => MaybePromise<void>
186+
entries?: () => MaybePromise<Array<[key: string, value: TStorageValue]>>
121187
}
122188
```
123189

@@ -129,5 +195,6 @@ The default options are:
129195
maxAge = 1000 * 60 * 60 * 24,
130196
serialize = JSON.stringify,
131197
deserialize = JSON.parse,
198+
refetchOnRestore = true,
132199
}
133200
```

packages/query-persist-client-core/src/__tests__/createPersister.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,62 @@ describe('createPersister', () => {
260260
expect(query.fetch).toHaveBeenCalledTimes(1)
261261
})
262262

263+
test('should restore item from the storage and refetch when `refetchOnRestore` is set to `always`', async () => {
264+
const storage = getFreshStorage()
265+
const { context, persister, query, queryFn, storageKey } = setupPersister(
266+
['foo'],
267+
{
268+
storage,
269+
refetchOnRestore: 'always',
270+
},
271+
)
272+
273+
await storage.setItem(
274+
storageKey,
275+
JSON.stringify({
276+
buster: '',
277+
state: { dataUpdatedAt: Date.now() + 1000, data: '' },
278+
}),
279+
)
280+
281+
await persister.persisterFn(queryFn, context, query)
282+
query.state.isInvalidated = true
283+
query.fetch = vi.fn()
284+
285+
await vi.advanceTimersByTimeAsync(0)
286+
287+
expect(queryFn).toHaveBeenCalledTimes(0)
288+
expect(query.fetch).toHaveBeenCalledTimes(1)
289+
})
290+
291+
test('should restore item from the storage and NOT refetch when `refetchOnRestore` is set to false', async () => {
292+
const storage = getFreshStorage()
293+
const { context, persister, query, queryFn, storageKey } = setupPersister(
294+
['foo'],
295+
{
296+
storage,
297+
refetchOnRestore: false,
298+
},
299+
)
300+
301+
await storage.setItem(
302+
storageKey,
303+
JSON.stringify({
304+
buster: '',
305+
state: { dataUpdatedAt: Date.now(), data: '' },
306+
}),
307+
)
308+
309+
await persister.persisterFn(queryFn, context, query)
310+
query.state.isInvalidated = true
311+
query.fetch = vi.fn()
312+
313+
await vi.advanceTimersByTimeAsync(0)
314+
315+
expect(queryFn).toHaveBeenCalledTimes(0)
316+
expect(query.fetch).toHaveBeenCalledTimes(0)
317+
})
318+
263319
test('should store item after successful fetch', async () => {
264320
const storage = getFreshStorage()
265321
const {

packages/query-persist-client-core/src/createPersister.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ export interface StoragePersisterOptions<TStorageValue = string> {
6262
* @default 'tanstack-query'
6363
*/
6464
prefix?: string
65+
/**
66+
* If set to `true`, the query will refetch on successful query restoration if the data is stale.
67+
* If set to `false`, the query will not refetch on successful query restoration.
68+
* If set to `'always'`, the query will always refetch on successful query restoration.
69+
* Defaults to `true`.
70+
*/
71+
refetchOnRestore?: boolean | 'always'
6572
/**
6673
* Filters to narrow down which Queries should be persisted.
6774
*/
@@ -96,6 +103,7 @@ export function experimental_createQueryPersister<TStorageValue = string>({
96103
StoragePersisterOptions<TStorageValue>
97104
>['deserialize'],
98105
prefix = PERSISTER_KEY_PREFIX,
106+
refetchOnRestore = true,
99107
filters,
100108
}: StoragePersisterOptions<TStorageValue>) {
101109
function isExpiredOrBusted(persistedQuery: PersistedQuery) {
@@ -204,7 +212,10 @@ export function experimental_createQueryPersister<TStorageValue = string>({
204212
errorUpdatedAt: persistedQuery.state.errorUpdatedAt,
205213
})
206214

207-
if (query.isStale()) {
215+
if (
216+
refetchOnRestore === 'always' ||
217+
(refetchOnRestore === true && query.isStale())
218+
) {
208219
query.fetch()
209220
}
210221
},

0 commit comments

Comments
 (0)