From 5a0aab79cef1f12c148f212dc97035e8789eccdc Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sun, 30 Jan 2022 14:45:33 +0100 Subject: [PATCH 01/20] feat(persistQueryClient): PersistQueryClientProvider --- .../PersistQueryClientProvider.tsx | 46 ++++++ src/persistQueryClient/index.ts | 144 +---------------- src/persistQueryClient/persist.ts | 142 +++++++++++++++++ .../tests/PersistQueryClientProvider.test.tsx | 148 ++++++++++++++++++ src/reactjs/QueryClientProvider.tsx | 5 +- 5 files changed, 341 insertions(+), 144 deletions(-) create mode 100644 src/persistQueryClient/PersistQueryClientProvider.tsx create mode 100644 src/persistQueryClient/persist.ts create mode 100644 src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx diff --git a/src/persistQueryClient/PersistQueryClientProvider.tsx b/src/persistQueryClient/PersistQueryClientProvider.tsx new file mode 100644 index 0000000000..e9859aac89 --- /dev/null +++ b/src/persistQueryClient/PersistQueryClientProvider.tsx @@ -0,0 +1,46 @@ +import React from 'react' + +import { persistQueryClient, PersistQueryClientOptions } from './persist' +import { QueryClientProvider, QueryClientProviderProps } from '../reactjs' + +export interface PersistQueryClientProviderProps + extends QueryClientProviderProps { + persistOptions: Omit + loading?: React.ReactNode +} + +export const PersistQueryClientProvider = ({ + client, + loading, + children, + persistOptions, + ...props +}: PersistQueryClientProviderProps): JSX.Element => { + const [initialized, setInitialized] = React.useState(false) + const options = React.useRef(persistOptions) + React.useEffect(() => { + let unsubscribe: (() => void) | undefined + + async function run() { + unsubscribe = await persistQueryClient({ + ...options.current, + queryClient: client, + }) + setInitialized(true) + } + + void run() + + return unsubscribe + }, [client]) + + if (!initialized) { + return <>{loading} + } + + return ( + + {children} + + ) +} diff --git a/src/persistQueryClient/index.ts b/src/persistQueryClient/index.ts index d38ed924b7..0ee1179f5f 100644 --- a/src/persistQueryClient/index.ts +++ b/src/persistQueryClient/index.ts @@ -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 - restoreClient(): Promisable - removeClient(): Promisable -} - -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' diff --git a/src/persistQueryClient/persist.ts b/src/persistQueryClient/persist.ts new file mode 100644 index 0000000000..4770b96eae --- /dev/null +++ b/src/persistQueryClient/persist.ts @@ -0,0 +1,142 @@ +import { + QueryClient, + dehydrate, + DehydratedState, + DehydrateOptions, + HydrateOptions, + hydrate, +} from '../core' +import { getLogger } from '../core/logger' +import { Promisable } from 'type-fest' + +export interface Persister { + persistClient(persistClient: PersistedClient): Promisable + restoreClient(): Promisable + removeClient(): Promisable +} + +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) + } +} diff --git a/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx new file mode 100644 index 0000000000..89c04bc0cd --- /dev/null +++ b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx @@ -0,0 +1,148 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react' + +import { QueryClient, useQuery, UseQueryResult } from '../..' +import { queryKey } from '../../reactjs/tests/utils' +import { sleep } from '../../core/utils' +import { PersistedClient, Persister, persistQueryClientSave } from '../persist' +import { PersistQueryClientProvider } from '../PersistQueryClientProvider' + +const createMockPersister = (): Persister => { + let storedState: PersistedClient | undefined + + return { + async persistClient(persistClient: PersistedClient) { + storedState = persistClient + }, + async restoreClient() { + return storedState + }, + removeClient() { + storedState = undefined + }, + } +} + +describe('PersistQueryClientProvider', () => { + test('restores cache from persister', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + + const queryClient = new QueryClient() + await queryClient.prefetchQuery(key, () => Promise.resolve('prefetched')) + + const persister = createMockPersister() + + await persistQueryClientSave({ queryClient, persister }) + + queryClient.clear() + + const initialData = jest.fn().mockReturnValue('initial') + + function Page() { + const state = useQuery( + key, + async () => { + await sleep(10) + return 'test' + }, + { + initialData, + } + ) + + states.push(state) + + return ( +
+

{state.data}

+
+ ) + } + + const rendered = render( + + + + ) + + await waitFor(() => rendered.getByText('loading')) + await waitFor(() => rendered.getByText('prefetched')) + await waitFor(() => rendered.getByText('test')) + + expect(states).toHaveLength(2) + + expect(states[0]).toMatchObject({ + status: 'success', + isFetching: true, + data: 'prefetched', + }) + + expect(states[1]).toMatchObject({ + status: 'success', + isFetching: false, + data: 'test', + }) + + expect(initialData).toHaveBeenCalledTimes(0) + }) + test('should not refetch after restoring when data is fresh', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + + const queryClient = new QueryClient() + await queryClient.prefetchQuery(key, () => Promise.resolve('prefetched')) + + const persister = createMockPersister() + + await persistQueryClientSave({ queryClient, persister }) + + queryClient.clear() + + function Page() { + const state = useQuery( + key, + async () => { + await sleep(10) + return 'test' + }, + { + staleTime: Infinity, + } + ) + + states.push(state) + + return ( +
+

{state.data}

+
+ ) + } + + const rendered = render( + + + + ) + + await waitFor(() => rendered.getByText('loading')) + await waitFor(() => rendered.getByText('prefetched')) + + expect(states).toHaveLength(1) + + expect(states[0]).toMatchObject({ + status: 'success', + isFetching: false, + data: 'prefetched', + }) + }) +}) diff --git a/src/reactjs/QueryClientProvider.tsx b/src/reactjs/QueryClientProvider.tsx index e0ea8baa52..f66ac9a70c 100644 --- a/src/reactjs/QueryClientProvider.tsx +++ b/src/reactjs/QueryClientProvider.tsx @@ -43,14 +43,15 @@ export const useQueryClient = () => { export interface QueryClientProviderProps { client: QueryClient + children?: React.ReactNode contextSharing?: boolean } -export const QueryClientProvider: React.FC = ({ +export const QueryClientProvider = ({ client, contextSharing = false, children, -}) => { +}: QueryClientProviderProps): JSX.Element => { React.useEffect(() => { client.mount() return () => { From 5c4de9153af481ea4ab3944c2590a23be454f29f Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sun, 6 Feb 2022 09:23:17 +0100 Subject: [PATCH 02/20] feat(persistQueryClient): PersistQueryClientProvider defer subscription if we are hydrating --- src/core/queryObserver.ts | 5 +- src/core/types.ts | 8 +- .../PersistQueryClientProvider.tsx | 18 +-- .../tests/PersistQueryClientProvider.test.tsx | 137 +++++++++++++++--- src/reactjs/Hydrate.tsx | 5 + src/reactjs/index.ts | 7 +- src/reactjs/useBaseQuery.ts | 38 +++-- src/reactjs/useQueries.ts | 30 ++-- 8 files changed, 182 insertions(+), 66 deletions(-) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 9dacf75669..45f8ffa213 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -440,7 +440,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) @@ -456,6 +456,9 @@ export class QueryObserver< status = 'loading' } } + if (options._optimisticResults === 'isHydrating') { + fetchStatus = 'paused' + } } // Keep previous data if needed diff --git a/src/core/types.ts b/src/core/types.ts index f177030963..37877f5656 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -202,12 +202,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 - /** - * 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 = Omit & Required> diff --git a/src/persistQueryClient/PersistQueryClientProvider.tsx b/src/persistQueryClient/PersistQueryClientProvider.tsx index e9859aac89..6a6779f53d 100644 --- a/src/persistQueryClient/PersistQueryClientProvider.tsx +++ b/src/persistQueryClient/PersistQueryClientProvider.tsx @@ -1,22 +1,24 @@ import React from 'react' import { persistQueryClient, PersistQueryClientOptions } from './persist' -import { QueryClientProvider, QueryClientProviderProps } from '../reactjs' +import { + QueryClientProvider, + QueryClientProviderProps, + IsHydratingProvider, +} from '../reactjs' export interface PersistQueryClientProviderProps extends QueryClientProviderProps { persistOptions: Omit - loading?: React.ReactNode } export const PersistQueryClientProvider = ({ client, - loading, children, persistOptions, ...props }: PersistQueryClientProviderProps): JSX.Element => { - const [initialized, setInitialized] = React.useState(false) + const [isHydrating, setIsHydrating] = React.useState(true) const options = React.useRef(persistOptions) React.useEffect(() => { let unsubscribe: (() => void) | undefined @@ -26,7 +28,7 @@ export const PersistQueryClientProvider = ({ ...options.current, queryClient: client, }) - setInitialized(true) + setIsHydrating(false) } void run() @@ -34,13 +36,9 @@ export const PersistQueryClientProvider = ({ return unsubscribe }, [client]) - if (!initialized) { - return <>{loading} - } - return ( - {children} + {children} ) } diff --git a/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx index 89c04bc0cd..9665e1be0b 100644 --- a/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx +++ b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx @@ -29,7 +29,7 @@ describe('PersistQueryClientProvider', () => { const states: UseQueryResult[] = [] const queryClient = new QueryClient() - await queryClient.prefetchQuery(key, () => Promise.resolve('prefetched')) + await queryClient.prefetchQuery(key, () => Promise.resolve('hydrated')) const persister = createMockPersister() @@ -37,17 +37,87 @@ describe('PersistQueryClientProvider', () => { queryClient.clear() - const initialData = jest.fn().mockReturnValue('initial') + function Page() { + const state = useQuery(key, async () => { + await sleep(10) + return 'fetched' + }) + + states.push(state) + + return ( +
+

{state.data}

+

fetchStatus: {state.fetchStatus}

+
+ ) + } + + const rendered = render( + + + + ) + + await waitFor(() => rendered.getByText('fetchStatus: paused')) + await waitFor(() => rendered.getByText('hydrated')) + await waitFor(() => rendered.getByText('fetched')) + + expect(states).toHaveLength(4) + + expect(states[0]).toMatchObject({ + status: 'loading', + fetchStatus: 'paused', + data: undefined, + }) + + expect(states[1]).toMatchObject({ + status: 'success', + fetchStatus: 'fetching', + data: 'hydrated', + }) + + expect(states[2]).toMatchObject({ + status: 'success', + fetchStatus: 'fetching', + data: 'hydrated', + }) + + expect(states[3]).toMatchObject({ + status: 'success', + fetchStatus: 'idle', + data: 'fetched', + }) + }) + + test('should show initialData while restoring', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + + const queryClient = new QueryClient() + await queryClient.prefetchQuery(key, () => Promise.resolve('hydrated')) + + const persister = createMockPersister() + + await persistQueryClientSave({ queryClient, persister }) + + queryClient.clear() function Page() { const state = useQuery( key, async () => { await sleep(10) - return 'test' + return 'fetched' }, { - initialData, + initialData: 'initial', + // make sure that initial data is older than the hydration data + // otherwise initialData would be newer and takes precedence + initialDataUpdatedAt: 1, } ) @@ -56,6 +126,7 @@ describe('PersistQueryClientProvider', () => { return (

{state.data}

+

fetchStatus: {state.fetchStatus}

) } @@ -64,38 +135,48 @@ describe('PersistQueryClientProvider', () => { ) - await waitFor(() => rendered.getByText('loading')) - await waitFor(() => rendered.getByText('prefetched')) - await waitFor(() => rendered.getByText('test')) + await waitFor(() => rendered.getByText('initial')) + await waitFor(() => rendered.getByText('hydrated')) + await waitFor(() => rendered.getByText('fetched')) - expect(states).toHaveLength(2) + expect(states).toHaveLength(4) expect(states[0]).toMatchObject({ status: 'success', - isFetching: true, - data: 'prefetched', + fetchStatus: 'paused', + data: 'initial', }) expect(states[1]).toMatchObject({ status: 'success', - isFetching: false, - data: 'test', + fetchStatus: 'fetching', + data: 'hydrated', }) - expect(initialData).toHaveBeenCalledTimes(0) + expect(states[2]).toMatchObject({ + status: 'success', + fetchStatus: 'fetching', + data: 'hydrated', + }) + + expect(states[3]).toMatchObject({ + status: 'success', + fetchStatus: 'idle', + data: 'fetched', + }) }) + test('should not refetch after restoring when data is fresh', async () => { const key = queryKey() const states: UseQueryResult[] = [] const queryClient = new QueryClient() - await queryClient.prefetchQuery(key, () => Promise.resolve('prefetched')) + await queryClient.prefetchQuery(key, () => Promise.resolve('hydrated')) const persister = createMockPersister() @@ -108,7 +189,7 @@ describe('PersistQueryClientProvider', () => { key, async () => { await sleep(10) - return 'test' + return 'fetched' }, { staleTime: Infinity, @@ -120,6 +201,7 @@ describe('PersistQueryClientProvider', () => { return (

{state.data}

+

fetchStatus: {state.fetchStatus}

) } @@ -128,21 +210,32 @@ describe('PersistQueryClientProvider', () => { ) - await waitFor(() => rendered.getByText('loading')) - await waitFor(() => rendered.getByText('prefetched')) + await waitFor(() => rendered.getByText('fetchStatus: paused')) + await waitFor(() => rendered.getByText('fetchStatus: idle')) - expect(states).toHaveLength(1) + expect(states).toHaveLength(3) expect(states[0]).toMatchObject({ + status: 'loading', + fetchStatus: 'paused', + data: undefined, + }) + + expect(states[1]).toMatchObject({ + status: 'success', + fetchStatus: 'idle', + data: 'hydrated', + }) + + expect(states[2]).toMatchObject({ status: 'success', - isFetching: false, - data: 'prefetched', + fetchStatus: 'idle', + data: 'hydrated', }) }) }) diff --git a/src/reactjs/Hydrate.tsx b/src/reactjs/Hydrate.tsx index 5221897403..1e7bea606d 100644 --- a/src/reactjs/Hydrate.tsx +++ b/src/reactjs/Hydrate.tsx @@ -3,6 +3,11 @@ import React from 'react' import { hydrate, HydrateOptions } from '../core' import { useQueryClient } from './QueryClientProvider' +const IsHydratingContext = React.createContext(false) + +export const useIsHydrating = () => React.useContext(IsHydratingContext) +export const IsHydratingProvider = IsHydratingContext.Provider + export function useHydrate(state: unknown, options?: HydrateOptions) { const queryClient = useQueryClient() diff --git a/src/reactjs/index.ts b/src/reactjs/index.ts index e8bcdee432..d854a5718d 100644 --- a/src/reactjs/index.ts +++ b/src/reactjs/index.ts @@ -13,7 +13,12 @@ export { useMutation } from './useMutation' export { useQuery } from './useQuery' export { useQueries } from './useQueries' export { useInfiniteQuery } from './useInfiniteQuery' -export { useHydrate, Hydrate } from './Hydrate' +export { + useHydrate, + Hydrate, + IsHydratingProvider, + useIsHydrating, +} from './Hydrate' // Types export * from './types' diff --git a/src/reactjs/useBaseQuery.ts b/src/reactjs/useBaseQuery.ts index 3a99bd1938..005fb4acbd 100644 --- a/src/reactjs/useBaseQuery.ts +++ b/src/reactjs/useBaseQuery.ts @@ -7,6 +7,7 @@ import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary' import { useQueryClient } from './QueryClientProvider' import { UseBaseQueryOptions } from './types' import { shouldThrowError } from './utils' +import { useIsHydrating } from './Hydrate' export function useBaseQuery< TQueryFnData, @@ -28,11 +29,14 @@ export function useBaseQuery< const [, forceUpdate] = React.useState(0) const queryClient = useQueryClient() + const isHydrating = useIsHydrating() const errorResetBoundary = useQueryErrorResetBoundary() const defaultedOptions = queryClient.defaultQueryOptions(options) // Make sure results are optimistically set in fetching state before subscribing or updating options - defaultedOptions.optimisticResults = true + defaultedOptions._optimisticResults = isHydrating + ? 'isHydrating' + : 'optimistic' // Include callbacks in batch renders if (defaultedOptions.onError) { @@ -81,25 +85,29 @@ export function useBaseQuery< React.useEffect(() => { mountedRef.current = true - errorResetBoundary.clearReset() + let unsubscribe: (() => void) | undefined - const unsubscribe = observer.subscribe( - notifyManager.batchCalls(() => { - if (mountedRef.current) { - forceUpdate(x => x + 1) - } - }) - ) + if (!isHydrating) { + errorResetBoundary.clearReset() - // Update result to make sure we did not miss any query updates - // between creating the observer and subscribing to it. - observer.updateResult() + unsubscribe = observer.subscribe( + notifyManager.batchCalls(() => { + if (mountedRef.current) { + forceUpdate(x => x + 1) + } + }) + ) + + // Update result to make sure we did not miss any query updates + // between creating the observer and subscribing to it. + observer.updateResult() + } return () => { mountedRef.current = false - unsubscribe() + unsubscribe?.() } - }, [errorResetBoundary, observer]) + }, [isHydrating, errorResetBoundary, observer]) React.useEffect(() => { // Do not notify on updates because of changes in the options because @@ -108,7 +116,7 @@ export function useBaseQuery< }, [defaultedOptions, observer]) // Handle suspense - if (defaultedOptions.suspense && result.isLoading) { + if (defaultedOptions.suspense && result.isLoading && !isHydrating) { throw observer .fetchOptimistic(defaultedOptions) .then(({ data }) => { diff --git a/src/reactjs/useQueries.ts b/src/reactjs/useQueries.ts index 4fa4fe3022..e747bd40ee 100644 --- a/src/reactjs/useQueries.ts +++ b/src/reactjs/useQueries.ts @@ -5,6 +5,7 @@ import { notifyManager } from '../core/notifyManager' import { QueriesObserver } from '../core/queriesObserver' import { useQueryClient } from './QueryClientProvider' import { UseQueryOptions, UseQueryResult } from './types' +import { useIsHydrating } from './Hydrate' // Avoid TS depth-limit error in case of large array literal type MAXIMUM_DEPTH = 20 @@ -119,6 +120,7 @@ export function useQueries({ const [, forceUpdate] = React.useState(0) const queryClient = useQueryClient() + const isHydrating = useIsHydrating() const defaultedQueries = React.useMemo( () => @@ -126,11 +128,13 @@ export function useQueries({ const defaultedOptions = queryClient.defaultQueryOptions(options) // Make sure the results are already in fetching state before subscribing or updating options - defaultedOptions.optimisticResults = true + defaultedOptions._optimisticResults = isHydrating + ? 'isHydrating' + : 'optimistic' return defaultedOptions }), - [queries, queryClient] + [queries, queryClient, isHydrating] ) const [observer] = React.useState( @@ -142,19 +146,23 @@ export function useQueries({ React.useEffect(() => { mountedRef.current = true - const unsubscribe = observer.subscribe( - notifyManager.batchCalls(() => { - if (mountedRef.current) { - forceUpdate(x => x + 1) - } - }) - ) + let unsubscribe: (() => void) | undefined + + if (!isHydrating) { + unsubscribe = observer.subscribe( + notifyManager.batchCalls(() => { + if (mountedRef.current) { + forceUpdate(x => x + 1) + } + }) + ) + } return () => { mountedRef.current = false - unsubscribe() + unsubscribe?.() } - }, [observer]) + }, [isHydrating, observer]) React.useEffect(() => { // Do not notify on updates because of changes in the options because From 261777ccc9491ade8fdb0fb5325db8177368fa96 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Mon, 7 Feb 2022 08:50:04 +0100 Subject: [PATCH 03/20] feat(persistQueryClient): PersistQueryClientProvider make sure we do not subscribe if the component unmounts before restoring has finished --- .../PersistQueryClientProvider.tsx | 15 +++++------- src/persistQueryClient/persist.ts | 24 ++++++++++++++----- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/persistQueryClient/PersistQueryClientProvider.tsx b/src/persistQueryClient/PersistQueryClientProvider.tsx index 6a6779f53d..3560f32b03 100644 --- a/src/persistQueryClient/PersistQueryClientProvider.tsx +++ b/src/persistQueryClient/PersistQueryClientProvider.tsx @@ -21,17 +21,14 @@ export const PersistQueryClientProvider = ({ const [isHydrating, setIsHydrating] = React.useState(true) const options = React.useRef(persistOptions) React.useEffect(() => { - let unsubscribe: (() => void) | undefined + const [unsubscribe, promise] = persistQueryClient({ + ...options.current, + queryClient: client, + }) - async function run() { - unsubscribe = await persistQueryClient({ - ...options.current, - queryClient: client, - }) + promise.then(() => { setIsHydrating(false) - } - - void run() + }) return unsubscribe }, [client]) diff --git a/src/persistQueryClient/persist.ts b/src/persistQueryClient/persist.ts index 4770b96eae..a0f55d2130 100644 --- a/src/persistQueryClient/persist.ts +++ b/src/persistQueryClient/persist.ts @@ -129,14 +129,26 @@ export function persistQueryClientSubscribe( /** * Restores persisted data to QueryCache and persists further changes. - * (Retained for backwards compatibility) */ -export async function persistQueryClient(props: PersistQueryClientOptions) { +export function persistQueryClient( + props: PersistQueryClientOptions +): [() => void, Promise] { + let hasUnsubscribed = false + let unsubscribe = () => { + hasUnsubscribed = true + } + + let restorePromise = Promise.resolve() + if (typeof window !== 'undefined') { // Attempt restore - await persistQueryClientRestore(props) - - // Subscribe to changes in the query cache to trigger the save - return persistQueryClientSubscribe(props) + restorePromise = persistQueryClientRestore(props).then(() => { + if (!hasUnsubscribed) { + // Subscribe to changes in the query cache to trigger the save + unsubscribe = persistQueryClientSubscribe(props) + } + }) } + + return [unsubscribe, restorePromise] } From 0f6de7a7dac5a0a80f7c9a8e3e6ef55a9db76ba1 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Thu, 10 Feb 2022 20:13:05 +0100 Subject: [PATCH 04/20] feat(persistQueryClient): PersistQueryClientProvider make unsubscribe a const so that we don't mutate what we've exposed --- src/persistQueryClient/persist.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/persistQueryClient/persist.ts b/src/persistQueryClient/persist.ts index a0f55d2130..71810732b1 100644 --- a/src/persistQueryClient/persist.ts +++ b/src/persistQueryClient/persist.ts @@ -134,8 +134,10 @@ export function persistQueryClient( props: PersistQueryClientOptions ): [() => void, Promise] { let hasUnsubscribed = false - let unsubscribe = () => { + let persistQueryClientUnsubscribe: (() => void) | undefined + const unsubscribe = () => { hasUnsubscribed = true + persistQueryClientUnsubscribe?.() } let restorePromise = Promise.resolve() @@ -145,7 +147,7 @@ export function persistQueryClient( restorePromise = persistQueryClientRestore(props).then(() => { if (!hasUnsubscribed) { // Subscribe to changes in the query cache to trigger the save - unsubscribe = persistQueryClientSubscribe(props) + persistQueryClientUnsubscribe = persistQueryClientSubscribe(props) } }) } From 363e3f7835db1a134d0608b8edc2778df867b3fc Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 12 Feb 2022 15:02:21 +0100 Subject: [PATCH 05/20] feat(persistQueryClient): PersistQueryClientProvider make hydrating queries go in fetchStatus: 'idle' instead of paused because paused means we have started fetching and are pausing, and we will also continue, while with hydration, we haven't started fetching, and we also might not start if we get "fresh" data from hydration --- src/core/queryObserver.ts | 2 +- .../tests/PersistQueryClientProvider.test.tsx | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index c0c8f8969f..c842500f69 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -435,7 +435,7 @@ export class QueryObserver< } } if (options._optimisticResults === 'isHydrating') { - fetchStatus = 'paused' + fetchStatus = 'idle' } } diff --git a/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx index 9665e1be0b..c6fad3042c 100644 --- a/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx +++ b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx @@ -62,7 +62,7 @@ describe('PersistQueryClientProvider', () => { ) - await waitFor(() => rendered.getByText('fetchStatus: paused')) + await waitFor(() => rendered.getByText('fetchStatus: idle')) await waitFor(() => rendered.getByText('hydrated')) await waitFor(() => rendered.getByText('fetched')) @@ -70,7 +70,7 @@ describe('PersistQueryClientProvider', () => { expect(states[0]).toMatchObject({ status: 'loading', - fetchStatus: 'paused', + fetchStatus: 'idle', data: undefined, }) @@ -148,7 +148,7 @@ describe('PersistQueryClientProvider', () => { expect(states[0]).toMatchObject({ status: 'success', - fetchStatus: 'paused', + fetchStatus: 'idle', data: 'initial', }) @@ -200,7 +200,7 @@ describe('PersistQueryClientProvider', () => { return (
-

{state.data}

+

data: {state.data ?? 'null'}

fetchStatus: {state.fetchStatus}

) @@ -215,14 +215,14 @@ describe('PersistQueryClientProvider', () => { ) - await waitFor(() => rendered.getByText('fetchStatus: paused')) - await waitFor(() => rendered.getByText('fetchStatus: idle')) + await waitFor(() => rendered.getByText('data: null')) + await waitFor(() => rendered.getByText('data: hydrated')) expect(states).toHaveLength(3) expect(states[0]).toMatchObject({ status: 'loading', - fetchStatus: 'paused', + fetchStatus: 'idle', data: undefined, }) From 459ac56847df2bbada7e26039057db50262b0bb5 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 12 Feb 2022 15:10:06 +0100 Subject: [PATCH 06/20] feat(persistQueryClient): PersistQueryClientProvider don't export IsHydratingProvider, as it shouldn't be needed by consumers --- src/reactjs/index.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/reactjs/index.ts b/src/reactjs/index.ts index a361ac822e..980c4a9dea 100644 --- a/src/reactjs/index.ts +++ b/src/reactjs/index.ts @@ -13,12 +13,7 @@ export { useMutation } from './useMutation' export { useQuery } from './useQuery' export { useQueries } from './useQueries' export { useInfiniteQuery } from './useInfiniteQuery' -export { - useHydrate, - Hydrate, - IsHydratingProvider, - useIsHydrating, -} from './Hydrate' +export { useHydrate, Hydrate, useIsHydrating } from './Hydrate' // Types export * from './types' From b301ce21be39acffca9a044fe130b991849e5968 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 12 Feb 2022 15:23:37 +0100 Subject: [PATCH 07/20] feat(persistQueryClient): PersistQueryClientProvider provide onSuccess and onError callbacks to PersistQueryClientProvider so that you can react to the persisting having finished, to e.g. have a point where you can resumePausedMutations --- .../PersistQueryClientProvider.tsx | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/persistQueryClient/PersistQueryClientProvider.tsx b/src/persistQueryClient/PersistQueryClientProvider.tsx index 3560f32b03..6b100217f7 100644 --- a/src/persistQueryClient/PersistQueryClientProvider.tsx +++ b/src/persistQueryClient/PersistQueryClientProvider.tsx @@ -1,34 +1,46 @@ import React from 'react' import { persistQueryClient, PersistQueryClientOptions } from './persist' -import { - QueryClientProvider, - QueryClientProviderProps, - IsHydratingProvider, -} from '../reactjs' +import { QueryClientProvider, QueryClientProviderProps } from '../reactjs' +import { IsHydratingProvider } from '../reactjs/Hydrate' export interface PersistQueryClientProviderProps extends QueryClientProviderProps { persistOptions: Omit + onSuccess?: () => void + onError?: () => void } export const PersistQueryClientProvider = ({ client, children, persistOptions, + onSuccess, + onError, ...props }: PersistQueryClientProviderProps): JSX.Element => { const [isHydrating, setIsHydrating] = React.useState(true) - const options = React.useRef(persistOptions) + const refs = React.useRef({ persistOptions, onSuccess, onError }) + + React.useEffect(() => { + refs.current = { persistOptions, onSuccess, onError } + }) + React.useEffect(() => { const [unsubscribe, promise] = persistQueryClient({ - ...options.current, + ...refs.current.persistOptions, queryClient: client, }) - promise.then(() => { - setIsHydrating(false) - }) + promise + .then(() => { + refs.current.onSuccess?.() + setIsHydrating(false) + }) + .catch(() => { + refs.current.onError?.() + setIsHydrating(false) + }) return unsubscribe }, [client]) From c86607f059a2169ebd5fa516e573620b04e49271 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 12 Feb 2022 20:48:13 +0100 Subject: [PATCH 08/20] feat(persistQueryClient): PersistQueryClientProvider tests for onSuccess callback, and remove onError callback, because the persister itself catches errors and removes the store --- .../PersistQueryClientProvider.tsx | 19 +++----- .../tests/PersistQueryClientProvider.test.tsx | 44 +++++++++++++++++++ 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/persistQueryClient/PersistQueryClientProvider.tsx b/src/persistQueryClient/PersistQueryClientProvider.tsx index 6b100217f7..9d7fa1750e 100644 --- a/src/persistQueryClient/PersistQueryClientProvider.tsx +++ b/src/persistQueryClient/PersistQueryClientProvider.tsx @@ -8,7 +8,6 @@ export interface PersistQueryClientProviderProps extends QueryClientProviderProps { persistOptions: Omit onSuccess?: () => void - onError?: () => void } export const PersistQueryClientProvider = ({ @@ -16,14 +15,13 @@ export const PersistQueryClientProvider = ({ children, persistOptions, onSuccess, - onError, ...props }: PersistQueryClientProviderProps): JSX.Element => { const [isHydrating, setIsHydrating] = React.useState(true) - const refs = React.useRef({ persistOptions, onSuccess, onError }) + const refs = React.useRef({ persistOptions, onSuccess }) React.useEffect(() => { - refs.current = { persistOptions, onSuccess, onError } + refs.current = { persistOptions, onSuccess } }) React.useEffect(() => { @@ -32,15 +30,10 @@ export const PersistQueryClientProvider = ({ queryClient: client, }) - promise - .then(() => { - refs.current.onSuccess?.() - setIsHydrating(false) - }) - .catch(() => { - refs.current.onError?.() - setIsHydrating(false) - }) + promise.then(() => { + refs.current.onSuccess?.() + setIsHydrating(false) + }) return unsubscribe }, [client]) diff --git a/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx index c6fad3042c..7dd2f3714e 100644 --- a/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx +++ b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx @@ -238,4 +238,48 @@ describe('PersistQueryClientProvider', () => { data: 'hydrated', }) }) + + test('should call onSuccess after successful restoring', async () => { + const key = queryKey() + + const queryClient = new QueryClient() + await queryClient.prefetchQuery(key, () => Promise.resolve('hydrated')) + + const persister = createMockPersister() + + await persistQueryClientSave({ queryClient, persister }) + + queryClient.clear() + + function Page() { + const state = useQuery(key, async () => { + await sleep(10) + return 'fetched' + }) + + return ( +
+

{state.data}

+

fetchStatus: {state.fetchStatus}

+
+ ) + } + + const onSuccess = jest.fn() + + const rendered = render( + + + + ) + expect(onSuccess).toHaveBeenCalledTimes(0) + + await waitFor(() => rendered.getByText('hydrated')) + expect(onSuccess).toHaveBeenCalledTimes(1) + await waitFor(() => rendered.getByText('fetched')) + }) }) From da52e82f46868697cd62220d4ddb3114b93a5b46 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 12 Feb 2022 20:57:56 +0100 Subject: [PATCH 09/20] feat(persistQueryClient): PersistQueryClientProvider test for useQueries --- .../tests/PersistQueryClientProvider.test.tsx | 78 ++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx index 7dd2f3714e..1daa1f6a3a 100644 --- a/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx +++ b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx @@ -1,7 +1,7 @@ import React from 'react' import { render, waitFor } from '@testing-library/react' -import { QueryClient, useQuery, UseQueryResult } from '../..' +import { QueryClient, useQuery, UseQueryResult, useQueries } from '../..' import { queryKey } from '../../reactjs/tests/utils' import { sleep } from '../../core/utils' import { PersistedClient, Persister, persistQueryClientSave } from '../persist' @@ -93,6 +93,82 @@ describe('PersistQueryClientProvider', () => { }) }) + test('should also put useQueries into idle state', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + + const queryClient = new QueryClient() + await queryClient.prefetchQuery(key, () => Promise.resolve('hydrated')) + + const persister = createMockPersister() + + await persistQueryClientSave({ queryClient, persister }) + + queryClient.clear() + + function Page() { + const [state] = useQueries({ + queries: [ + { + queryKey: key, + queryFn: async (): Promise => { + await sleep(10) + return 'fetched' + }, + }, + ], + }) + + states.push(state) + + return ( +
+

{state.data}

+

fetchStatus: {state.fetchStatus}

+
+ ) + } + + const rendered = render( + + + + ) + + await waitFor(() => rendered.getByText('fetchStatus: idle')) + await waitFor(() => rendered.getByText('hydrated')) + await waitFor(() => rendered.getByText('fetched')) + + expect(states).toHaveLength(4) + + expect(states[0]).toMatchObject({ + status: 'loading', + fetchStatus: 'idle', + data: undefined, + }) + + expect(states[1]).toMatchObject({ + status: 'success', + fetchStatus: 'fetching', + data: 'hydrated', + }) + + expect(states[2]).toMatchObject({ + status: 'success', + fetchStatus: 'fetching', + data: 'hydrated', + }) + + expect(states[3]).toMatchObject({ + status: 'success', + fetchStatus: 'idle', + data: 'fetched', + }) + }) + test('should show initialData while restoring', async () => { const key = queryKey() const states: UseQueryResult[] = [] From 00a6bcdc326f1c5289bedae777014abdcf0d4e4d Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 12 Feb 2022 21:20:17 +0100 Subject: [PATCH 10/20] feat(persistQueryClient): PersistQueryClientProvider docs --- docs/src/pages/plugins/persistQueryClient.md | 52 ++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/src/pages/plugins/persistQueryClient.md b/docs/src/pages/plugins/persistQueryClient.md index 75aaac23af..c835a93cb0 100644 --- a/docs/src/pages/plugins/persistQueryClient.md +++ b/docs/src/pages/plugins/persistQueryClient.md @@ -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(, 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( + + + , + rootElement +) + +``` + ## How does it work? - A check for window `undefined` is performed prior to saving/restoring/removing your data (avoids build errors). From fc0044295b5ec1e567b3423b645b1d528a6436fb Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 26 Feb 2022 19:37:42 +0100 Subject: [PATCH 11/20] make restore in mockPersister a bit slower to stabilize tests --- src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx index 1daa1f6a3a..1a4b84b968 100644 --- a/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx +++ b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx @@ -15,6 +15,7 @@ const createMockPersister = (): Persister => { storedState = persistClient }, async restoreClient() { + await sleep(10) return storedState }, removeClient() { From 879495a33da736aca7f84a5d61625a97ac9cd3fb Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sun, 27 Feb 2022 09:42:35 +0100 Subject: [PATCH 12/20] better persistQueryClient docs --- docs/src/pages/plugins/persistQueryClient.md | 18 ++++++++++++++---- docs/src/pages/reference/QueryClient.md | 9 +++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/src/pages/plugins/persistQueryClient.md b/docs/src/pages/plugins/persistQueryClient.md index c835a93cb0..387c7dd447 100644 --- a/docs/src/pages/plugins/persistQueryClient.md +++ b/docs/src/pages/plugins/persistQueryClient.md @@ -50,11 +50,11 @@ 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. +[persistQueryClient](#persistQueryClient) will try to restore the cache and automatically subscribes 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: +Further, if you subscribe to changes outside of the React component lifecycle, you have no way of unsubscribing: ```js // 🚨 never unsubscribes from syncing @@ -69,7 +69,7 @@ ReactDOM.render(, 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`: +For this use-case, you can use the `PersistQueryClientProvider`. It will make sure to subscribe / unsubscribe correctly according to the React component 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](../reference/QueryClientProvider): ```jsx @@ -97,9 +97,19 @@ ReactDOM.render( , rootElement ) - ``` +#### Props + +`PersistQueryClientProvider` takes the same props as [QueryClientProvider](../reference/QueryClientProvider), and additionally: + +- `persistOptions: PersistQueryClientOptions` + - all [options](#options) you cann pass to [persistQueryClient](#persistqueryclient) minus the QueryClient itself +- `onSuccess?: () => void` + - optional + - will be called when the initial restore is finished + - can be used to [resumePausedMutations](../reference/QueryClient#queryclientresumepausedmutations) + ## How does it work? - A check for window `undefined` is performed prior to saving/restoring/removing your data (avoids build errors). diff --git a/docs/src/pages/reference/QueryClient.md b/docs/src/pages/reference/QueryClient.md index 3e4c2daf74..2996693544 100644 --- a/docs/src/pages/reference/QueryClient.md +++ b/docs/src/pages/reference/QueryClient.md @@ -49,6 +49,7 @@ Its available methods are: - [`queryClient.getQueryCache`](#queryclientgetquerycache) - [`queryClient.getMutationCache`](#queryclientgetmutationcache) - [`queryClient.clear`](#queryclientclear) +- - [`queryClient.resumePausedMutations`](#queryclientresumepausedmutations) **Options** @@ -563,3 +564,11 @@ The `clear` method clears all connected caches. ```js queryClient.clear() ``` + +## `queryClient.resumePausedMutations` + +Can be used to resume mutations that have been paused because there was no network connection. + +```js +queryClient.resumePausedMutations() +``` From 507edca196dc4facd54f6bc5ee7ea7402ee268bc Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Mon, 28 Feb 2022 16:05:47 +0100 Subject: [PATCH 13/20] feat(PersistQueryClientProvider): make sure we can hydrate into multiple clients and error handling --- .../PersistQueryClientProvider.tsx | 26 +++- .../tests/PersistQueryClientProvider.test.tsx | 114 ++++++++++++++++++ 2 files changed, 134 insertions(+), 6 deletions(-) diff --git a/src/persistQueryClient/PersistQueryClientProvider.tsx b/src/persistQueryClient/PersistQueryClientProvider.tsx index 9d7fa1750e..20802d2397 100644 --- a/src/persistQueryClient/PersistQueryClientProvider.tsx +++ b/src/persistQueryClient/PersistQueryClientProvider.tsx @@ -8,6 +8,7 @@ export interface PersistQueryClientProviderProps extends QueryClientProviderProps { persistOptions: Omit onSuccess?: () => void + onError?: (error: unknown) => Promise | void } export const PersistQueryClientProvider = ({ @@ -15,25 +16,38 @@ export const PersistQueryClientProvider = ({ children, persistOptions, onSuccess, + onError, ...props }: PersistQueryClientProviderProps): JSX.Element => { const [isHydrating, setIsHydrating] = React.useState(true) - const refs = React.useRef({ persistOptions, onSuccess }) + const refs = React.useRef({ persistOptions, onSuccess, onError }) + const previousPromise = React.useRef(Promise.resolve()) React.useEffect(() => { - refs.current = { persistOptions, onSuccess } + refs.current = { persistOptions, onSuccess, onError } }) React.useEffect(() => { + setIsHydrating(true) const [unsubscribe, promise] = persistQueryClient({ ...refs.current.persistOptions, queryClient: client, }) - promise.then(() => { - refs.current.onSuccess?.() - setIsHydrating(false) - }) + async function handlePersist() { + try { + await previousPromise.current + previousPromise.current = promise + await promise + refs.current.onSuccess?.() + } catch (error) { + refs.current.onError?.(error) + } finally { + setIsHydrating(false) + } + } + + void handlePersist() return unsubscribe }, [client]) diff --git a/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx index 1a4b84b968..0027a01c16 100644 --- a/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx +++ b/src/persistQueryClient/tests/PersistQueryClientProvider.test.tsx @@ -359,4 +359,118 @@ describe('PersistQueryClientProvider', () => { expect(onSuccess).toHaveBeenCalledTimes(1) await waitFor(() => rendered.getByText('fetched')) }) + + test('should be able to persist into multiple clients', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + + const queryClient = new QueryClient() + await queryClient.prefetchQuery(key, () => Promise.resolve('hydrated')) + + const persister = createMockPersister() + + await persistQueryClientSave({ queryClient, persister }) + + queryClient.clear() + + const onSuccess = jest.fn() + + const queryFn1 = jest.fn().mockImplementation(async () => { + await sleep(10) + return 'queryFn1' + }) + const queryFn2 = jest.fn().mockImplementation(async () => { + await sleep(10) + return 'queryFn2' + }) + + function App() { + const [client, setClient] = React.useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + queryFn: queryFn1, + }, + }, + }) + ) + + React.useEffect(() => { + setClient( + new QueryClient({ + defaultOptions: { + queries: { + queryFn: queryFn2, + }, + }, + }) + ) + }, []) + + return ( + + + + ) + } + + function Page() { + const state = useQuery(key) + + states.push(state) + + return ( +
+

{String(state.data)}

+

fetchStatus: {state.fetchStatus}

+
+ ) + } + + const rendered = render() + + await waitFor(() => rendered.getByText('hydrated')) + await waitFor(() => rendered.getByText('queryFn2')) + + expect(queryFn1).toHaveBeenCalledTimes(0) + expect(queryFn2).toHaveBeenCalledTimes(1) + expect(onSuccess).toHaveBeenCalledTimes(2) + + expect(states).toHaveLength(5) + + expect(states[0]).toMatchObject({ + status: 'loading', + fetchStatus: 'idle', + data: undefined, + }) + + expect(states[1]).toMatchObject({ + status: 'loading', + fetchStatus: 'idle', + data: undefined, + }) + + expect(states[2]).toMatchObject({ + status: 'success', + fetchStatus: 'fetching', + data: 'hydrated', + }) + + expect(states[3]).toMatchObject({ + status: 'success', + fetchStatus: 'fetching', + data: 'hydrated', + }) + + expect(states[4]).toMatchObject({ + status: 'success', + fetchStatus: 'idle', + data: 'queryFn2', + }) + }) }) From 702159fa4c5bc05a52d849601a381efb366b6cf2 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sun, 13 Mar 2022 16:53:49 +0100 Subject: [PATCH 14/20] offline example --- docs/src/manifests/manifest.json | 5 + docs/src/pages/examples/offline.mdx | 23 ++ examples/offline/.babelrc | 3 + examples/offline/.eslintrc | 7 + examples/offline/.gitignore | 26 ++ examples/offline/.prettierrc | 1 + examples/offline/.rescriptsrc.js | 37 ++ examples/offline/README.md | 6 + examples/offline/package.json | 36 ++ examples/offline/public/favicon.ico | 1 + examples/offline/public/index.html | 38 +++ examples/offline/public/manifest.json | 15 + examples/offline/public/mockServiceWorker.js | 338 +++++++++++++++++++ examples/offline/src/App.js | 244 +++++++++++++ examples/offline/src/api.js | 67 ++++ examples/offline/src/index.js | 16 + 16 files changed, 863 insertions(+) create mode 100644 docs/src/pages/examples/offline.mdx create mode 100644 examples/offline/.babelrc create mode 100644 examples/offline/.eslintrc create mode 100644 examples/offline/.gitignore create mode 100644 examples/offline/.prettierrc create mode 100644 examples/offline/.rescriptsrc.js create mode 100644 examples/offline/README.md create mode 100644 examples/offline/package.json create mode 100644 examples/offline/public/favicon.ico create mode 100644 examples/offline/public/index.html create mode 100644 examples/offline/public/manifest.json create mode 100644 examples/offline/public/mockServiceWorker.js create mode 100644 examples/offline/src/App.js create mode 100644 examples/offline/src/api.js create mode 100644 examples/offline/src/index.js diff --git a/docs/src/manifests/manifest.json b/docs/src/manifests/manifest.json index ac3a029884..7a6f9d746d 100644 --- a/docs/src/manifests/manifest.json +++ b/docs/src/manifests/manifest.json @@ -326,6 +326,11 @@ "title": "React Native", "path": "/examples/react-native", "editUrl": "/examples/react-native.mdx" + }, + { + "title": "Offline Queries and Mutations", + "path": "/examples/offline", + "editUrl": "/examples/offline.mdx" } ] }, diff --git a/docs/src/pages/examples/offline.mdx b/docs/src/pages/examples/offline.mdx new file mode 100644 index 0000000000..d7dc18a4f7 --- /dev/null +++ b/docs/src/pages/examples/offline.mdx @@ -0,0 +1,23 @@ +--- +id: offline +title: Offline Queries and Mutations +toc: false +--- + +- [Open in CodeSandbox](https://codesandbox.io/s/github/tannerlinsley/react-query/tree/alpha/examples/offline) +- [View Source](https://github.com/tannerlinsley/react-query/tree/alpha/examples/offline) + + diff --git a/examples/offline/.babelrc b/examples/offline/.babelrc new file mode 100644 index 0000000000..c14b2828d1 --- /dev/null +++ b/examples/offline/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["react-app"] +} diff --git a/examples/offline/.eslintrc b/examples/offline/.eslintrc new file mode 100644 index 0000000000..404725ad66 --- /dev/null +++ b/examples/offline/.eslintrc @@ -0,0 +1,7 @@ +{ + "extends": ["react-app", "prettier"], + "rules": { + // "eqeqeq": 0, + // "jsx-a11y/anchor-is-valid": 0 + } +} diff --git a/examples/offline/.gitignore b/examples/offline/.gitignore new file mode 100644 index 0000000000..613b2de638 --- /dev/null +++ b/examples/offline/.gitignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +yarn.lock +package-lock.json + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/offline/.prettierrc b/examples/offline/.prettierrc new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/examples/offline/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/examples/offline/.rescriptsrc.js b/examples/offline/.rescriptsrc.js new file mode 100644 index 0000000000..433b258d85 --- /dev/null +++ b/examples/offline/.rescriptsrc.js @@ -0,0 +1,37 @@ +const path = require("path"); +const resolveFrom = require("resolve-from"); + +const fixLinkedDependencies = (config) => { + config.resolve = { + ...config.resolve, + alias: { + ...config.resolve.alias, + react$: resolveFrom(path.resolve("node_modules"), "react"), + "react-dom$": resolveFrom(path.resolve("node_modules"), "react-dom"), + }, + }; + return config; +}; + +const includeSrcDirectory = (config) => { + config.resolve = { + ...config.resolve, + modules: [path.resolve("src"), ...config.resolve.modules], + }; + return config; +}; + +const allowOutsideSrc = (config) => { + config.resolve.plugins = config.resolve.plugins.filter( + (p) => p.constructor.name !== "ModuleScopePlugin" + ); + return config; +}; + +module.exports = [ + ["use-babel-config", ".babelrc"], + ["use-eslint-config", ".eslintrc"], + fixLinkedDependencies, + allowOutsideSrc, + // includeSrcDirectory, +]; diff --git a/examples/offline/README.md b/examples/offline/README.md new file mode 100644 index 0000000000..b168d3c4b1 --- /dev/null +++ b/examples/offline/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` or `yarn` +- `npm run start` or `yarn start` diff --git a/examples/offline/package.json b/examples/offline/package.json new file mode 100644 index 0000000000..ce0f6a88ce --- /dev/null +++ b/examples/offline/package.json @@ -0,0 +1,36 @@ +{ + "private": true, + "scripts": { + "start": "rescripts start", + "build": "rescripts build", + "test": "rescripts test", + "eject": "rescripts eject" + }, + "dependencies": { + "@tanstack/react-location": "^3.7.0", + "ky": "^0.30.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-hot-toast": "^2.2.0", + "react-query": "^4.0.0-alpha.19", + "react-scripts": "3.0.1" + }, + "devDependencies": { + "@rescripts/cli": "^0.0.11", + "@rescripts/rescript-use-babel-config": "^0.0.8", + "@rescripts/rescript-use-eslint-config": "^0.0.9", + "babel-eslint": "10.0.1" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/offline/public/favicon.ico b/examples/offline/public/favicon.ico new file mode 100644 index 0000000000..41cff3b9db --- /dev/null +++ b/examples/offline/public/favicon.ico @@ -0,0 +1 @@ +https://rawcdn.githack.com/tannerlinsley/react-query/master/examples/simple/public/favicon.ico \ No newline at end of file diff --git a/examples/offline/public/index.html b/examples/offline/public/index.html new file mode 100644 index 0000000000..dd1ccfd4cd --- /dev/null +++ b/examples/offline/public/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + React App + + + +
+ + + diff --git a/examples/offline/public/manifest.json b/examples/offline/public/manifest.json new file mode 100644 index 0000000000..1f2f141faf --- /dev/null +++ b/examples/offline/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/offline/public/mockServiceWorker.js b/examples/offline/public/mockServiceWorker.js new file mode 100644 index 0000000000..ba0c013b83 --- /dev/null +++ b/examples/offline/public/mockServiceWorker.js @@ -0,0 +1,338 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (0.39.1). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929' +const bypassHeaderName = 'x-msw-bypass' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + return self.skipWaiting() +}) + +self.addEventListener('activate', async function (event) { + return self.clients.claim() +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll() + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +// Resolve the "main" client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll() + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: serializeHeaders(clonedResponse.headers), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +async function getResponse(event, client, requestId) { + const { request } = event + const requestClone = request.clone() + const getOriginalResponse = () => fetch(requestClone) + + // Bypass mocking when the request client is not active. + if (!client) { + return getOriginalResponse() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return await getOriginalResponse() + } + + // Bypass requests with the explicit bypass header + if (requestClone.headers.get(bypassHeaderName) === 'true') { + const cleanRequestHeaders = serializeHeaders(requestClone.headers) + + // Remove the bypass header to comply with the CORS preflight check. + delete cleanRequestHeaders[bypassHeaderName] + + const originalRequest = new Request(requestClone, { + headers: new Headers(cleanRequestHeaders), + }) + + return fetch(originalRequest) + } + + // Send the request to the client-side MSW. + const reqHeaders = serializeHeaders(request.headers) + const body = await request.text() + + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: reqHeaders, + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body, + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_SUCCESS': { + return delayPromise( + () => respondWithMock(clientMessage), + clientMessage.payload.delay, + ) + } + + case 'MOCK_NOT_FOUND': { + return getOriginalResponse() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.payload + const networkError = new Error(message) + networkError.name = name + + // Rejecting a request Promise emulates a network error. + throw networkError + } + + case 'INTERNAL_ERROR': { + const parsedBody = JSON.parse(clientMessage.payload.body) + + console.error( + `\ +[MSW] Uncaught exception in the request handler for "%s %s": + +${parsedBody.location} + +This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ +`, + request.method, + request.url, + ) + + return respondWithMock(clientMessage) + } + } + + return getOriginalResponse() +} + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = uuidv4() + + return event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +function serializeHeaders(headers) { + const reqHeaders = {} + headers.forEach((value, name) => { + reqHeaders[name] = reqHeaders[name] + ? [].concat(reqHeaders[name]).concat(value) + : value + }) + return reqHeaders +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(JSON.stringify(message), [channel.port2]) + }) +} + +function delayPromise(cb, duration) { + return new Promise((resolve) => { + setTimeout(() => resolve(cb()), duration) + }) +} + +function respondWithMock(clientMessage) { + return new Response(clientMessage.payload.body, { + ...clientMessage.payload, + headers: clientMessage.payload.headers, + }) +} + +function uuidv4() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0 + const v = c == 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} diff --git a/examples/offline/src/App.js b/examples/offline/src/App.js new file mode 100644 index 0000000000..1cd0e67d46 --- /dev/null +++ b/examples/offline/src/App.js @@ -0,0 +1,244 @@ +import * as React from "react"; +import "./App.css"; + +import { + useQuery, + QueryClient, + MutationCache, + useMutation, + onlineManager, +} from "react-query"; +import { ReactQueryDevtools } from "react-query/devtools"; +import toast, { Toaster } from "react-hot-toast"; + +import { PersistQueryClientProvider } from "react-query/persistQueryClient"; +import { createWebStoragePersister } from "react-query/createWebStoragePersister"; +import { + Link, + Outlet, + ReactLocation, + Router, + useMatch, +} from "@tanstack/react-location"; + +import * as api from "api"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + cacheTime: 1000 * 60 * 60 * 24, // 24 hours + staleTime: 2000, + retry: 0, + }, + }, + // configure global cache callbacks to show toast notifications + mutationCache: new MutationCache({ + onSuccess: (data) => { + toast.success(data.message); + }, + onError: (error) => { + toast.error(error.message); + }, + }), +}); + +// we need a default mutation function so that paused mutations can resume after a page reload +queryClient.setMutationDefaults(["movies"], { + mutationFn: async ({ id, comment }) => { + // to avoid clashes with our optimistic update when an offline mutation continues + await queryClient.cancelQueries(["movies", id]); + return api.updateMovie(id, comment); + }, +}); + +const persister = createWebStoragePersister({ + storage: window.localStorage, +}); + +const location = new ReactLocation(); + +export default function App() { + return ( + { + // resume mutations after initial restore from localStorage was successful + queryClient.resumePausedMutations().then(() => { + queryClient.invalidateQueries(); + }); + }} + > + , + }, + { + path: ":movieId", + element: , + errorElement: , + loader: ({ params: { movieId } }) => + queryClient.getQueryData(["movies", movieId]) ?? + // do not load if we are offline because it returns a promise that is pending until we go online again + // we just let the Detail component handle it + (onlineManager.isOnline() + ? queryClient.fetchQuery(["movies", movieId], () => + api.fetchMovie(movieId) + ) + : undefined), + }, + ]} + > + + + + + + ); +} + +function List() { + const moviesQuery = useQuery(["movies"], api.fetchMovies); + + if (moviesQuery.isLoading && moviesQuery.isFetching) { + return "Loading..."; + } + + if (moviesQuery.data) { + return ( +
+

Movies

+

+ Try to mock offline behaviour with the button in the devtools. You can + navigate around as long as there is already data in the cache. You'll + get a refetch as soon as you go online again. +

+
    + {moviesQuery.data.movies.map((movie) => ( +
  • + + {movie.title} + +
  • + ))} +
+
+ Updated at: {new Date(moviesQuery.data.ts).toLocaleTimeString()} +
+
{moviesQuery.isFetching && "fetching..."}
+
+ ); + } + + // query will be in 'idle' fetchStatus while restoring from localStorage + return null; +} + +function MovieError() { + const { error } = useMatch(); + + return ( +
+ Back +

Couldn't load movie!

+
{error.message}
+
+ ); +} + +function Detail() { + const { + params: { movieId }, + } = useMatch(); + + const [comment, setComment] = React.useState(); + + const movieQuery = useQuery(["movies", movieId], () => + api.fetchMovie(movieId) + ); + const updateMovie = useMutation({ + mutationKey: ["movies", movieId], + onMutate: async () => { + await queryClient.cancelQueries(["movies", movieId]); + const previousData = queryClient.getQueryData(["movies", movieId]); + + queryClient.setQueryData(["movies", movieId], { + ...previousData, + movie: { + ...previousData.movie, + comment, + }, + }); + + return { previousData }; + }, + onError: (_, __, context) => { + queryClient.setQueryData(["movies", movieId], context.previousData); + }, + onSettled: () => { + // remove local state so that server state is taken instead + setComment(undefined); + queryClient.invalidateQueries(["movies", movieId]); + }, + }); + + if (movieQuery.isLoading && movieQuery.isFetching) { + return "Loading..."; + } + + function submitForm(event) { + event.preventDefault(); + + updateMovie.mutate({ + id: movieId, + comment, + }); + } + + if (movieQuery.data) { + return ( +
+ Back +

Movie: {movieQuery.data.movie.title}

+

+ Try to mock offline behaviour with the button in the devtools, then + update the comment. The optimistic update will succeed, but the actual + mutation will be paused and resumed once you go online again. +

+

+ You can also reload the page, which will make the persisted mutation + resume, as you will be online again when you "come back". +

+

+