Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5a0aab7
feat(persistQueryClient): PersistQueryClientProvider
TkDodo Jan 30, 2022
5c4de91
feat(persistQueryClient): PersistQueryClientProvider
TkDodo Feb 6, 2022
985c564
Merge remote-tracking branch 'react-query/alpha' into feature/persist…
TkDodo Feb 6, 2022
00d0ad4
Merge remote-tracking branch 'react-query/alpha' into feature/persist…
TkDodo Feb 6, 2022
261777c
feat(persistQueryClient): PersistQueryClientProvider
TkDodo Feb 7, 2022
0f6de7a
feat(persistQueryClient): PersistQueryClientProvider
TkDodo Feb 10, 2022
2d410fb
Merge remote-tracking branch 'react-query/alpha' into feature/persist…
TkDodo Feb 12, 2022
363e3f7
feat(persistQueryClient): PersistQueryClientProvider
TkDodo Feb 12, 2022
459ac56
feat(persistQueryClient): PersistQueryClientProvider
TkDodo Feb 12, 2022
b301ce2
feat(persistQueryClient): PersistQueryClientProvider
TkDodo Feb 12, 2022
82ed46d
Merge remote-tracking branch 'react-query/alpha' into feature/persist…
TkDodo Feb 12, 2022
c86607f
feat(persistQueryClient): PersistQueryClientProvider
TkDodo Feb 12, 2022
da52e82
feat(persistQueryClient): PersistQueryClientProvider
TkDodo Feb 12, 2022
00a6bcd
feat(persistQueryClient): PersistQueryClientProvider
TkDodo Feb 12, 2022
90ce9ff
Merge remote-tracking branch 'react-query/alpha' into feature/persist…
TkDodo Feb 26, 2022
2c21e75
Merge remote-tracking branch 'react-query/alpha' into feature/persist…
TkDodo Feb 26, 2022
74f9748
Merge remote-tracking branch 'react-query/alpha' into feature/persist…
TkDodo Feb 26, 2022
6bef21c
Merge branch 'alpha' into feature/persistQueryClient
TkDodo Feb 26, 2022
fc00442
make restore in mockPersister a bit slower to stabilize tests
TkDodo Feb 26, 2022
7befff0
Merge branch 'alpha' into feature/persistQueryClient
TkDodo Feb 26, 2022
9b05e02
Merge branch 'alpha' into feature/persistQueryClient
TkDodo Feb 27, 2022
879495a
better persistQueryClient docs
TkDodo Feb 27, 2022
c6565db
Merge branch 'alpha' into feature/persistQueryClient
TkDodo Feb 28, 2022
507edca
feat(PersistQueryClientProvider): make sure we can hydrate into multi…
TkDodo Feb 28, 2022
8f9c746
Merge remote-tracking branch 'react-query/alpha' into feature/persist…
TkDodo Mar 3, 2022
702159f
offline example
TkDodo Mar 13, 2022
4146a69
extract to custom hook
TkDodo Mar 13, 2022
291b0a5
Merge remote-tracking branch 'react-query/alpha' into feature/persist…
TkDodo Mar 13, 2022
e78a470
remove onError callback
TkDodo Mar 13, 2022
91e2afb
just ignore stale hydrations if the client changes
TkDodo Mar 19, 2022
29d52f8
Revert "just ignore stale hydrations if the client changes"
TkDodo Mar 19, 2022
433fd8a
just ignore stale hydrations if the client changes
TkDodo Mar 19, 2022
2570c8a
Merge remote-tracking branch 'tannerlinsley/alpha' into feature/persi…
TkDodo Mar 24, 2022
b2cd6b0
since QueryClientProviderProps is now a union type, we can't extend i…
TkDodo Mar 24, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/src/pages/plugins/persistQueryClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,58 @@ It should be set as the same value or higher than persistQueryClient's `maxAge`

You can also pass it `Infinity` to disable garbage collection behavior entirely.

## Usage with React

[persistQueryClient](#persistQueryClient) will try to restore the cache and automatically subscribes you to further changes, thus syncing your client to the provided storage.

However, restoring is asynchronous, because all persisters are async by nature, which means that if you render your App while you are restoring, you might get into race conditions if a query mounts and fetches at the same time.

Further, if you subscribe to changes outside of react lifecycles, you have no way of unsubscribing:

```js
// 🚨 never unsubscribes from syncing
persistQueryClient({
queryClient,
persister: localStoragePersister,
})

// 🚨 happens at the same time as restoring
ReactDOM.render(<App />, rootElement)
```

### PeristQueryClientProvider

For this use-case, you can use the `PersistQueryClientProvider`. It will make sure to subscribe / unsubscribe correctly according to the React lifecycle, and it will also make sure that queries will not start fetching while we are still restoring. Queries will still render though, they will just be put into `fetchingState: 'idle'` until data has been restored. Then, they will refetch unless the restored data is _fresh_ enough, and _initialData_ will also be respected. It can be used _instead of_ the normal `QueryClientProvider`:

```jsx

import { PersistQueryClientProvider } from 'react-query/persistQueryClient'
import { createWebStoragePersister } from 'react-query/createWebStoragePersister'

const queryClient = new QueryClient({
defaultOptions: {
queries: {
cacheTime: 1000 * 60 * 60 * 24, // 24 hours
},
},
})

const persister = createWebStoragePersister({
storage: window.localStorage,
})

ReactDOM.render(
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
>
<App />
</PersistQueryClientProvider>,
rootElement
)

```

## How does it work?

- A check for window `undefined` is performed prior to saving/restoring/removing your data (avoids build errors).
Expand Down
5 changes: 4 additions & 1 deletion src/core/queryObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ export class QueryObserver<
let data: TData | undefined

// Optimistically set result in fetching state if needed
if (options.optimisticResults) {
if (options._optimisticResults) {
const mounted = this.hasListeners()

const fetchOnMount = !mounted && shouldFetchOnMount(query, options)
Expand All @@ -434,6 +434,9 @@ export class QueryObserver<
status = 'loading'
}
}
if (options._optimisticResults === 'isHydrating') {
fetchStatus = 'idle'
}
}

// Keep previous data if needed
Expand Down
8 changes: 2 additions & 6 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,8 @@ export interface QueryObserverOptions<
* If set, this value will be used as the placeholder data for this particular query observer while the query is still in the `loading` data and no initialData has been provided.
*/
placeholderData?: TQueryData | PlaceholderDataFunction<TQueryData>
/**
* If set, the observer will optimistically set the result in fetching state before the query has actually started fetching.
* This is to make sure the results are not lagging behind.
* Defaults to `true`.
*/
optimisticResults?: boolean

_optimisticResults?: 'optimistic' | 'isHydrating'
}

type WithRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
Expand Down
46 changes: 46 additions & 0 deletions src/persistQueryClient/PersistQueryClientProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react'

import { persistQueryClient, PersistQueryClientOptions } from './persist'
import { QueryClientProvider, QueryClientProviderProps } from '../reactjs'
import { IsHydratingProvider } from '../reactjs/Hydrate'

export interface PersistQueryClientProviderProps
extends QueryClientProviderProps {
persistOptions: Omit<PersistQueryClientOptions, 'queryClient'>
onSuccess?: () => void
}

export const PersistQueryClientProvider = ({
client,
children,
persistOptions,
onSuccess,
...props
}: PersistQueryClientProviderProps): JSX.Element => {
const [isHydrating, setIsHydrating] = React.useState(true)
const refs = React.useRef({ persistOptions, onSuccess })

React.useEffect(() => {
refs.current = { persistOptions, onSuccess }
})

React.useEffect(() => {
const [unsubscribe, promise] = persistQueryClient({
...refs.current.persistOptions,
queryClient: client,
})

promise.then(() => {
refs.current.onSuccess?.()
setIsHydrating(false)
})

return unsubscribe
}, [client])

return (
<QueryClientProvider client={client} {...props}>
<IsHydratingProvider value={isHydrating}>{children}</IsHydratingProvider>
</QueryClientProvider>
)
}
144 changes: 2 additions & 142 deletions src/persistQueryClient/index.ts
Original file line number Diff line number Diff line change
@@ -1,142 +1,2 @@
import { QueryClient } from '../core'
import { getLogger } from '../core/logger'
import {
dehydrate,
DehydratedState,
DehydrateOptions,
HydrateOptions,
hydrate,
} from 'react-query'
import { Promisable } from 'type-fest'

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 PersistQueryClienRootOptions {
/** 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 PersistQueryClienRootOptions {
/** 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 PersistQueryClienRootOptions {
/** The options passed to the dehydrate function */
dehydrateOptions?: DehydrateOptions
}

export interface PersistQueryClientOptions
extends PersistedQueryClientRestoreOptions,
PersistedQueryClientSaveOptions,
PersistQueryClienRootOptions {}

/**
* 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) {
if (typeof window !== 'undefined') {
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) {
persister.removeClient()
} else {
hydrate(queryClient, persistedClient.clientState, hydrateOptions)
}
} else {
persister.removeClient()
}
}
} catch (err) {
getLogger().error(err)
getLogger().warn(
'Encountered an error attempting to restore client cache from persisted location. As a precaution, the persisted cache will be discarded.'
)
persister.removeClient()
}
}
}

/**
* Persists data from the QueryCache
* - data dehydrated using dehydrateOptions
* - data is persisted using persister.persistClient
*/
export async function persistQueryClientSave({
queryClient,
persister,
buster = '',
dehydrateOptions,
}: PersistedQueryClientSaveOptions) {
if (typeof window !== 'undefined') {
const persistClient: PersistedClient = {
buster,
timestamp: Date.now(),
clientState: dehydrate(queryClient, dehydrateOptions),
}

await persister.persistClient(persistClient)
}
}

/**
* Subscribe to QueryCache updates (for persisting)
* @returns an unsubscribe function (to discontinue monitoring)
*/
export function persistQueryClientSubscribe(
props: PersistedQueryClientSaveOptions
) {
return props.queryClient.getQueryCache().subscribe(() => {
persistQueryClientSave(props)
})
}

/**
* Restores persisted data to QueryCache and persists further changes.
* (Retained for backwards compatibility)
*/
export async function persistQueryClient(props: PersistQueryClientOptions) {
if (typeof window !== 'undefined') {
// Attempt restore
await persistQueryClientRestore(props)

// Subscribe to changes in the query cache to trigger the save
return persistQueryClientSubscribe(props)
}
}
export * from './persist'
export * from './PersistQueryClientProvider'
Loading