Skip to content

Commit 462c557

Browse files
feat: fine grained storage persister (#5125)
* feat: fine grained storage persister * feat: persister option * fix: invalidate query just after restoring * fix: types, prettier, added comments * fix: move pieces * fix: prettier * fix: add error handling * fix: drop client dependency, fix type inference * fix: formatting * fix: pass query as param * chore: some cleanup * test: add tests * test: fix import order * test: add react test for fine grained persister * feat: queryFilter predicate * fix: test import * docs: add docs and migration guide entry * docs and filters --------- Co-authored-by: Dominik Dorfmeister <[email protected]>
1 parent 3cba6d8 commit 462c557

File tree

17 files changed

+1030
-720
lines changed

17 files changed

+1030
-720
lines changed

docs/config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,10 @@
331331
{
332332
"label": "broadcastQueryClient (Experimental)",
333333
"to": "react/plugins/broadcastQueryClient"
334+
},
335+
{
336+
"label": "createPersister (Experimental)",
337+
"to": "react/plugins/createPersister"
334338
}
335339
]
336340
},

docs/react/guides/migrating-to-v5.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,10 @@ Infinite Queries can be prefetched like regular Queries. Per default, only the f
455455

456456
See the [useQueries docs](../reference/useQueries#combine) for more details.
457457

458+
### Experimental `fine grained storage persister`
459+
460+
See the [experimental_createPersister docs](../plugins/createPersister) for more details.
461+
458462
[//]: # 'FrameworkSpecificNewFeatures'
459463

460464
### Typesafe way to create Query Options

docs/react/plugins/createPersister.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
---
2+
id: createPersister
3+
title: experimental_createPersister
4+
---
5+
6+
## Installation
7+
8+
This utility comes as a separate package and is available under the `'@tanstack/query-persist-client-core'` import.
9+
10+
```bash
11+
npm install @tanstack/query-persist-client-core
12+
```
13+
14+
or
15+
16+
```bash
17+
pnpm add @tanstack/query-persist-client-core
18+
```
19+
20+
or
21+
22+
```bash
23+
yarn add @tanstack/query-persist-client-core
24+
```
25+
26+
> Note: This util is also included in the `@tanstack/react-query-persist-client` package, so you do not need to install it separately if you are using that package.
27+
28+
## Usage
29+
30+
- Import the `experimental_createPersister` function
31+
- Create a new `experimental_createPersister`
32+
- you can pass any `storage` to it that adheres to the `AsyncStorage` or `Storage` interface - the example below uses the async-storage from React Native.
33+
- 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.
34+
- 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.
35+
- If you provide this `persister` to a single `useQuery` hook, only this Query will be persisted.
36+
37+
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.
38+
39+
```tsx
40+
import AsyncStorage from '@react-native-async-storage/async-storage'
41+
import { QueryClient } from '@tanstack/react-query'
42+
import { experimental_createPersister } from '@tanstack/query-persist-client-core'
43+
44+
const queryClient = new QueryClient({
45+
defaultOptions: {
46+
queries: {
47+
gcTime: 1000 * 60 * 60 * 24, // 24 hours
48+
persister: experimental_createPersister({
49+
storage: AsyncStorage,
50+
}),
51+
},
52+
},
53+
})
54+
```
55+
56+
## API
57+
58+
### `experimental_createPersister`
59+
60+
```tsx
61+
experimental_createPersister(options: StoragePersisterOptions)
62+
```
63+
64+
#### `Options`
65+
66+
```tsx
67+
export interface StoragePersisterOptions {
68+
/** The storage client used for setting and retrieving items from cache.
69+
* For SSR pass in `undefined`.
70+
*/
71+
storage: AsyncStorage | Storage | undefined | null
72+
/**
73+
* How to serialize the data to storage.
74+
* @default `JSON.stringify`
75+
*/
76+
serialize?: (persistedQuery: PersistedQuery) => string
77+
/**
78+
* How to deserialize the data from storage.
79+
* @default `JSON.parse`
80+
*/
81+
deserialize?: (cachedString: string) => PersistedQuery
82+
/**
83+
* A unique string that can be used to forcefully invalidate existing caches,
84+
* if they do not share the same buster string
85+
*/
86+
buster?: string
87+
/**
88+
* The max-allowed age of the cache in milliseconds.
89+
* If a persisted cache is found that is older than this
90+
* time, it will be discarded
91+
*/
92+
maxAge?: number
93+
/**
94+
* Prefix to be used for storage key.
95+
* Storage key is a combination of prefix and query hash in a form of `prefix-queryHash`.
96+
*/
97+
prefix?: string
98+
/**
99+
* Filters to narrow down which Queries should be persisted.
100+
*/
101+
filters?: QueryFilters
102+
}
103+
104+
interface AsyncStorage {
105+
getItem: (key: string) => Promise<string | undefined | null>
106+
setItem: (key: string, value: string) => Promise<unknown>
107+
removeItem: (key: string) => Promise<void>
108+
}
109+
```
110+
111+
The default options are:
112+
113+
```tsx
114+
{
115+
prefix = 'tanstack-query',
116+
maxAge = 1000 * 60 * 60 * 24,
117+
serialize = JSON.stringify,
118+
deserialize = JSON.parse,
119+
}
120+
```

examples/vue/persister/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
"serve": "vite preview"
99
},
1010
"dependencies": {
11+
"@tanstack/query-core": "^5.0.0-beta.0",
1112
"@tanstack/query-persist-client-core": "^5.0.0-beta.0",
1213
"@tanstack/query-sync-storage-persister": "^5.0.0-beta.0",
1314
"@tanstack/vue-query": "^5.0.0-beta.0",
14-
"vue": "^3.3.0"
15+
"vue": "^3.3.0",
16+
"idb-keyval": "^6.2.0"
1517
},
1618
"devDependencies": {
1719
"@vitejs/plugin-vue": "^4.2.3",

examples/vue/persister/src/Post.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<script lang="ts">
2+
import { get, set, del } from 'idb-keyval'
23
import { defineComponent } from 'vue'
34
import { useQuery } from '@tanstack/vue-query'
45
56
import { Post } from './types'
7+
import { experimental_createPersister } from '@tanstack/query-persist-client-core'
68
79
const fetcher = async (id: number): Promise<Post> =>
810
await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`).then(
@@ -20,8 +22,15 @@ export default defineComponent({
2022
emits: ['setPostId'],
2123
setup(props) {
2224
const { isPending, isError, isFetching, data, error } = useQuery({
23-
queryKey: ['post', props.postId],
25+
queryKey: ['post', props.postId] as const,
2426
queryFn: () => fetcher(props.postId),
27+
persister: experimental_createPersister({
28+
storage: {
29+
getItem: (key: string) => get(key),
30+
setItem: (key: string, value: string) => set(key, value),
31+
removeItem: (key: string) => del(key),
32+
},
33+
}),
2534
})
2635
2736
return { isPending, isError, isFetching, data, error }

examples/vue/persister/src/Posts.vue

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
<script lang="ts">
22
import { defineComponent } from 'vue'
33
import { useQuery } from '@tanstack/vue-query'
4+
import { experimental_createPersister } from '@tanstack/query-persist-client-core'
45
56
import { Post } from './types'
67
78
const fetcher = async (): Promise<Post[]> =>
8-
await fetch('https://jsonplaceholder.typicode.com/posts').then((response) =>
9-
response.json(),
9+
await fetch('https://jsonplaceholder.typicode.com/posts').then(
10+
(response) =>
11+
new Promise((resolve) =>
12+
setTimeout(() => resolve(response.json()), 1000),
13+
),
1014
)
1115
1216
export default defineComponent({
@@ -19,18 +23,39 @@ export default defineComponent({
1923
},
2024
emits: ['setPostId'],
2125
setup() {
22-
const { isPending, isError, isFetching, data, error, refetch } = useQuery({
23-
queryKey: ['posts'],
24-
queryFn: fetcher,
26+
const {
27+
isPending,
28+
isError,
29+
isFetching,
30+
isRefetching,
31+
data,
32+
error,
33+
refetch,
34+
} = useQuery({
35+
queryKey: ['posts'] as const,
36+
queryFn: () => fetcher(),
37+
persister: experimental_createPersister({
38+
storage: localStorage,
39+
}),
40+
staleTime: 5000,
2541
})
2642
27-
return { isPending, isError, isFetching, data, error, refetch }
43+
return {
44+
isPending,
45+
isRefetching,
46+
isError,
47+
isFetching,
48+
data,
49+
error,
50+
refetch,
51+
}
2852
},
2953
})
3054
</script>
3155

3256
<template>
3357
<h1>Posts</h1>
58+
<div v-if="isRefetching">Refetching...</div>
3459
<div v-if="isPending">Loading...</div>
3560
<div v-else-if="isError">An error has occurred: {{ error }}</div>
3661
<div v-else-if="data">

examples/vue/persister/src/main.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { createApp } from 'vue'
22
import { VueQueryPlugin } from '@tanstack/vue-query'
3-
import { persistQueryClient } from '@tanstack/query-persist-client-core'
4-
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
53
import type { VueQueryPluginOptions } from '@tanstack/vue-query'
64

75
import App from './App.vue'
@@ -11,16 +9,10 @@ const vueQueryOptions: VueQueryPluginOptions = {
119
defaultOptions: {
1210
queries: {
1311
gcTime: 1000 * 60 * 60 * 24,
14-
staleTime: 1000 * 60 * 60 * 24,
12+
// staleTime: 1000 * 10,
1513
},
1614
},
1715
},
18-
clientPersister: (queryClient) => {
19-
return persistQueryClient({
20-
queryClient,
21-
persister: createSyncStoragePersister({ storage: localStorage }),
22-
})
23-
},
2416
}
2517

2618
createApp(App).use(VueQueryPlugin, vueQueryOptions).mount('#app')

packages/query-async-storage-persister/src/index.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
import { asyncThrottle } from './asyncThrottle'
22
import type {
3+
AsyncStorage,
34
PersistedClient,
45
Persister,
56
Promisable,
67
} from '@tanstack/query-persist-client-core'
78

8-
interface AsyncStorage {
9-
getItem: (key: string) => Promise<string | null>
10-
setItem: (key: string, value: string) => Promise<unknown>
11-
removeItem: (key: string) => Promise<void>
12-
}
13-
149
export type AsyncPersistRetryer = (props: {
1510
persistedClient: PersistedClient
1611
error: Error

packages/query-core/src/infiniteQueryBehavior.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ export function infiniteQueryBehavior<TQueryFnData, TError, TData, TPageParam>(
1111
pages?: number,
1212
): QueryBehavior<TQueryFnData, TError, InfiniteData<TData, TPageParam>> {
1313
return {
14-
onFetch: (context) => {
15-
context.fetchFn = async () => {
14+
onFetch: (context, query) => {
15+
const fetchFn = async () => {
1616
const options = context.options as InfiniteQueryPageParamsOptions<TData>
1717
const direction = context.fetchOptions?.meta?.fetchMore?.direction
1818
const oldPages = context.state.data?.pages || []
@@ -114,6 +114,21 @@ export function infiniteQueryBehavior<TQueryFnData, TError, TData, TPageParam>(
114114

115115
return result
116116
}
117+
if (context.options.persister) {
118+
context.fetchFn = () => {
119+
return context.options.persister?.(
120+
fetchFn as any,
121+
{
122+
queryKey: context.queryKey,
123+
meta: context.options.meta,
124+
signal: context.signal,
125+
},
126+
query,
127+
)
128+
}
129+
} else {
130+
context.fetchFn = fetchFn
131+
}
117132
},
118133
}
119134
}

packages/query-core/src/query.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export interface QueryBehavior<
7171
> {
7272
onFetch: (
7373
context: FetchContext<TQueryFnData, TError, TData, TQueryKey>,
74+
query: Query,
7475
) => void
7576
}
7677

@@ -392,6 +393,14 @@ export class Query<
392393
)
393394
}
394395
this.#abortSignalConsumed = false
396+
if (this.options.persister) {
397+
return this.options.persister(
398+
this.options.queryFn,
399+
queryFnContext as QueryFunctionContext<TQueryKey>,
400+
this as unknown as Query,
401+
)
402+
}
403+
395404
return this.options.queryFn(
396405
queryFnContext as QueryFunctionContext<TQueryKey>,
397406
)
@@ -413,6 +422,7 @@ export class Query<
413422

414423
this.options.behavior?.onFetch(
415424
context as FetchContext<TQueryFnData, TError, TData, TQueryKey>,
425+
this as unknown as Query,
416426
)
417427

418428
// Store state in case the current fetch needs to be reverted

0 commit comments

Comments
 (0)