Repository: tannerlinsley/react-query
Pull request #2131 (2021-04-15 14:35:51)

Added hydrate and dehydrate options to the persistQueryClient function
The reason to add this is to be able to customize the hydrate/dehydrate behaviour on the persistQueryClient. For instance, I don't want a network error to remove a cache entry from my persisted state, I want the client cache to be equivalent to the persisted cache at all time, with dehydrate options, I can achieve this behaviour.

vercel[bot]: This pull request is being automatically deployed with Vercel ([learn more](https://vercel.link/github-learn-more)).  
To see the status of your deployment, click below or on the icon next to each commit.

🔍 Inspect: [https://vercel.com/tannerlinsley/react-query/Cv3ytrtxpN1bNpMzKuTLn6pvDRqr](https://vercel.com/tannerlinsley/react-query/Cv3ytrtxpN1bNpMzKuTLn6pvDRqr)  
✅ Preview: *In Progress*


codesandbox[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/tannerlinsley/react-query/pr/2131/builds/120159) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 59c2bb4b61f923cdf594ed0b0cb5b50324bc73bd:

|Sandbox| Source |
|--|--|
|[tannerlinsley/react-query: basic](https://codesandbox.io/s/tannerlinsleyreact-query-basic-1rwf5)| Configuration |
|[tannerlinsley/react-query: basic-typescript](https://codesandbox.io/s/tannerlinsleyreact-query-basic-typescript-1c41e)| Configuration |


Patch:
diff --git a/docs/src/pages/guides/filters.md b/docs/src/pages/guides/filters.md
index 132decaf9..9419e44d1 100644
--- a/docs/src/pages/guides/filters.md
+++ b/docs/src/pages/guides/filters.md
@@ -13,13 +13,13 @@ A query filter is an object with certain conditions to match a query with:
 // Cancel all queries
 await queryClient.cancelQueries()
 
-// Remove all inactive queries
+// Remove all inactive queries that begin with `posts` in the key
 queryClient.removeQueries('posts', { inactive: true })
 
 // Refetch all active queries
 await queryClient.refetchQueries({ active: true })
 
-// Refetch all active queries that begin with `post` in the key
+// Refetch all active queries that begin with `posts` in the key
 await queryClient.refetchQueries('posts', { active: true })
 ```
 
diff --git a/docs/src/pages/guides/migrating-to-react-query-3.md b/docs/src/pages/guides/migrating-to-react-query-3.md
index 9320a0abc..ed5d42798 100644
--- a/docs/src/pages/guides/migrating-to-react-query-3.md
+++ b/docs/src/pages/guides/migrating-to-react-query-3.md
@@ -48,6 +48,8 @@ const queryClient = new QueryClient()
 
 Default options for queries and mutations can now be specified in `QueryClient`:
 
+**Notice that it's now defaultOptions instead of defaultConfig**
+
 ```js
 const queryClient = new QueryClient({
   defaultOptions: {
diff --git a/docs/src/pages/guides/mutations.md b/docs/src/pages/guides/mutations.md
index 498c6f690..0869e7ff7 100644
--- a/docs/src/pages/guides/mutations.md
+++ b/docs/src/pages/guides/mutations.md
@@ -53,10 +53,10 @@ In the example above, you also saw that you can pass variables to your mutations
 
 Even with just variables, mutations aren't all that special, but when used with the `onSuccess` option, the [Query Client's `invalidateQueries` method](../reference/QueryClient#queryclientinvalidatequeries) and the [Query Client's `setQueryData` method](../reference/QueryClient#queryclientsetquerydata), mutations become a very powerful tool.
 
-> IMPORTANT: The `mutate` function is an asynchronous function, which means you cannot use it directly in an event callback. If you need to access the event in `onSubmit` you need to wrap `mutate` in another function. This is due to [React event pooling](https://reactjs.org/docs/events.html#event-pooling).
+> IMPORTANT: The `mutate` function is an asynchronous function, which means you cannot use it directly in an event callback in **React 16 and earlier**. If you need to access the event in `onSubmit` you need to wrap `mutate` in another function. This is due to [React event pooling](https://reactjs.org/docs/legacy-event-pooling.html).
 
 ```js
-// This will not work
+// This will not work in React 16 and earlier
 const CreateTodo = () => {
   const mutation = useMutation(event => {
     event.preventDefault()
diff --git a/docs/src/pages/guides/updates-from-mutation-responses.md b/docs/src/pages/guides/updates-from-mutation-responses.md
index f9b98105a..7e1c2e053 100644
--- a/docs/src/pages/guides/updates-from-mutation-responses.md
+++ b/docs/src/pages/guides/updates-from-mutation-responses.md
@@ -9,7 +9,9 @@ When dealing with mutations that **update** objects on the server, it's common f
 const queryClient = useQueryClient()
 
 const mutation = useMutation(editTodo, {
-  onSuccess: data => queryClient.setQueryData(['todo', { id: 5 }], data),
+  onSuccess: data => {
+    queryClient.setQueryData(['todo', { id: 5 }], data)
+  }
 })
 
 mutation.mutate({
diff --git a/docs/src/pages/overview.md b/docs/src/pages/overview.md
index ebf783df6..885e29a6a 100644
--- a/docs/src/pages/overview.md
+++ b/docs/src/pages/overview.md
@@ -20,7 +20,7 @@ Once you grasp the nature of server state in your application, **even more chall
 
 - Caching... (possibly the hardest thing to do in programming)
 - Deduping multiple requests for the same data into a single request
-- Updating out of date data in the background
+- Updating "out of date" data in the background
 - Knowing when data is "out of date"
 - Reflecting updates to data as quickly as possible
 - Performance optimizations like pagination and lazy loading data
diff --git a/docs/src/pages/plugins/persistQueryClient.md b/docs/src/pages/plugins/persistQueryClient.md
index 98e8a73f6..cadf18267 100644
--- a/docs/src/pages/plugins/persistQueryClient.md
+++ b/docs/src/pages/plugins/persistQueryClient.md
@@ -94,6 +94,10 @@ interface PersistQueryClientOptions {
   /** A unique string that can be used to forcefully
    * invalidate existing caches if they do not share the same buster string */
   buster?: string
+  /** The options passed to the hydrate function */
+  hydrateOptions?: HydrateOptions
+  /** The options passed to the dehydrate function */
+  dehydrateOptions?: DehydrateOptions
 }
 ```
 
diff --git a/docs/src/pages/reference/useQuery.md b/docs/src/pages/reference/useQuery.md
index d0c8c4ac3..dac5a0ac6 100644
--- a/docs/src/pages/reference/useQuery.md
+++ b/docs/src/pages/reference/useQuery.md
@@ -89,6 +89,8 @@ const result = useQuery({
   - A function like `attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000)` applies exponential backoff.
   - A function like `attempt => attempt * 1000` applies linear backoff.
 - `staleTime: number | Infinity`
+  - Optional
+  - Defaults to `0`
   - The time in milliseconds after data is considered stale. This value only applies to the hook it is defined on.
   - If set to `Infinity`, the data will never be considered stale
 - `cacheTime: number | Infinity`
diff --git a/src/core/focusManager.ts b/src/core/focusManager.ts
index d600adf27..2ab0bb95e 100644
--- a/src/core/focusManager.ts
+++ b/src/core/focusManager.ts
@@ -12,12 +12,12 @@ class FocusManager extends Subscribable {
   }
 
   setEventListener(
-    setup: (onFocus: () => void) => (focused?: boolean) => void
+    setup: (setFocused: (focused?: boolean) => void) => () => void
   ): void {
     if (this.removeEventListener) {
       this.removeEventListener()
     }
-    this.removeEventListener = setup((focused?: boolean) => {
+    this.removeEventListener = setup((focused) => {
       if (typeof focused === 'boolean') {
         this.setFocused(focused)
       } else {
@@ -58,14 +58,15 @@ class FocusManager extends Subscribable {
   private setDefaultEventListener() {
     if (!isServer && window?.addEventListener) {
       this.setEventListener(onFocus => {
+        const listener = () => onFocus()
         // Listen to visibillitychange and focus
-        window.addEventListener('visibilitychange', onFocus, false)
-        window.addEventListener('focus', onFocus, false)
+        window.addEventListener('visibilitychange', listener, false)
+        window.addEventListener('focus', listener, false)
 
         return () => {
           // Be sure to unsubscribe if a new handler is set
-          window.removeEventListener('visibilitychange', onFocus)
-          window.removeEventListener('focus', onFocus)
+          window.removeEventListener('visibilitychange', listener)
+          window.removeEventListener('focus', listener)
         }
       })
     }
diff --git a/src/core/infiniteQueryObserver.ts b/src/core/infiniteQueryObserver.ts
index dd9169644..2dd199ba8 100644
--- a/src/core/infiniteQueryObserver.ts
+++ b/src/core/infiniteQueryObserver.ts
@@ -75,6 +75,21 @@ export class InfiniteQueryObserver<
     })
   }
 
+  getOptimisticResult(
+    options: InfiniteQueryObserverOptions<
+      TQueryFnData,
+      TError,
+      TData,
+      TQueryData
+    >
+  ): InfiniteQueryObserverResult<TData, TError> {
+    options.behavior = infiniteQueryBehavior()
+    return super.getOptimisticResult(options) as InfiniteQueryObserverResult<
+      TData,
+      TError
+    >
+  }
+
   fetchNextPage(
     options?: FetchNextPageOptions
   ): Promise<InfiniteQueryObserverResult<TData, TError>> {
diff --git a/src/core/query.ts b/src/core/query.ts
index 64098b539..10394221a 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -1,6 +1,5 @@
 import {
   Updater,
-  ensureArray,
   functionalUpdate,
   isValidTimeout,
   noop,
@@ -343,6 +342,10 @@ export class Query<
     }
   }
 
+  getObserversCount(): number {
+    return this.observers.length
+  }
+
   invalidate(): void {
     if (!this.state.isInvalidated) {
       this.dispatch({ type: 'invalidate' })
@@ -378,9 +381,8 @@ export class Query<
     }
 
     // Create query function context
-    const queryKey = ensureArray(this.queryKey)
-    const queryFnContext: QueryFunctionContext<unknown[]> = {
-      queryKey,
+    const queryFnContext: QueryFunctionContext<TQueryKey> = {
+      queryKey: this.queryKey,
       pageParam: undefined,
     }
 
@@ -394,7 +396,7 @@ export class Query<
     const context: FetchContext<TQueryFnData, TError, TData, any> = {
       fetchOptions,
       options: this.options,
-      queryKey,
+      queryKey: this.queryKey,
       state: this.state,
       fetchFn,
     }
diff --git a/src/core/types.ts b/src/core/types.ts
index 39e16029a..e6df09785 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -59,7 +59,7 @@ export interface QueryOptions<
   retryDelay?: RetryDelayValue<TError>
   cacheTime?: number
   isDataEqual?: (oldData: TData | undefined, newData: TData) => boolean
-  queryFn?: QueryFunction<TQueryFnData>
+  queryFn?: QueryFunction<TQueryFnData, TQueryKey>
   queryHash?: string
   queryKey?: TQueryKey
   queryKeyHashFn?: QueryKeyHashFunction<TQueryKey>
diff --git a/src/devtools/devtools.tsx b/src/devtools/devtools.tsx
index 346bc247f..449567992 100644
--- a/src/devtools/devtools.tsx
+++ b/src/devtools/devtools.tsx
@@ -306,7 +306,7 @@ export function ReactQueryDevtools({
 }
 
 const getStatusRank = q =>
-  q.state.isFetching ? 0 : !q.observers.length ? 3 : q.isStale() ? 2 : 1
+  q.state.isFetching ? 0 : !q.getObserversCount() ? 3 : q.isStale() ? 2 : 1
 
 const sortFns = {
   'Status > Last Updated': (a, b) =>
@@ -613,7 +613,7 @@ export const ReactQueryDevtoolsPanel = React.forwardRef(
                           : 'white',
                     }}
                   >
-                    {query.observers.length}
+                    {query.getObserversCount()}
                   </div>
                   <Code
                     suppressHydrationWarning
@@ -689,7 +689,7 @@ export const ReactQueryDevtoolsPanel = React.forwardRef(
                     justifyContent: 'space-between',
                   }}
                 >
-                  Observers: <Code>{activeQuery.observers.length}</Code>
+                  Observers: <Code>{activeQuery.getObserversCount()}</Code>
                 </div>
                 <div
                   style={{
diff --git a/src/devtools/utils.ts b/src/devtools/utils.ts
index 9a5d94ba5..a5c7b7829 100644
--- a/src/devtools/utils.ts
+++ b/src/devtools/utils.ts
@@ -12,7 +12,7 @@ export function getQueryStatusColor(query, theme) {
     ? theme.active
     : query.isStale()
     ? theme.warning
-    : !query.observers.length
+    : !query.getObserversCount()
     ? theme.gray
     : theme.success
 }
@@ -20,7 +20,7 @@ export function getQueryStatusColor(query, theme) {
 export function getQueryStatusLabel(query) {
   return query.state.isFetching
     ? 'fetching'
-    : !query.observers.length
+    : !query.getObserversCount()
     ? 'inactive'
     : query.isStale()
     ? 'stale'
diff --git a/src/persistQueryClient-experimental/index.ts b/src/persistQueryClient-experimental/index.ts
index f93df7a10..550826536 100644
--- a/src/persistQueryClient-experimental/index.ts
+++ b/src/persistQueryClient-experimental/index.ts
@@ -1,6 +1,6 @@
 import { QueryClient } from '../core'
 import { getLogger } from '../core/logger'
-import { dehydrate, DehydratedState, hydrate } from '../hydration'
+import { dehydrate, DehydratedState, DehydrateOptions, HydrateOptions, hydrate } from '../hydration'
 import { Promisable } from 'type-fest'
 
 export interface Persistor {
@@ -28,6 +28,10 @@ export interface PersistQueryClientOptions {
   /** A unique string that can be used to forcefully
    * invalidate existing caches if they do not share the same buster string */
   buster?: string
+  /** The options passed to the hydrate function */
+  hydrateOptions?: HydrateOptions
+  /** The options passed to the dehydrate function */
+  dehydrateOptions?: DehydrateOptions
 }
 
 export async function persistQueryClient({
@@ -35,6 +39,8 @@ export async function persistQueryClient({
   persistor,
   maxAge = 1000 * 60 * 60 * 24,
   buster = '',
+  hydrateOptions,
+  dehydrateOptions,
 }: PersistQueryClientOptions) {
   if (typeof window !== 'undefined') {
     // Subscribe to changes
@@ -42,7 +48,7 @@ export async function persistQueryClient({
       const persistClient: PersistedClient = {
         buster,
         timestamp: Date.now(),
-        clientState: dehydrate(queryClient),
+        clientState: dehydrate(queryClient, dehydrateOptions),
       }
 
       persistor.persistClient(persistClient)
@@ -59,7 +65,7 @@ export async function persistQueryClient({
           if (expired || busted) {
             persistor.removeClient()
           } else {
-            hydrate(queryClient, persistedClient.clientState)
+            hydrate(queryClient, persistedClient.clientState, hydrateOptions)
           }
         } else {
           persistor.removeClient()
diff --git a/src/react/types.ts b/src/react/types.ts
index aea5c78e2..5a947fb9f 100644
--- a/src/react/types.ts
+++ b/src/react/types.ts
@@ -4,9 +4,11 @@ import {
   InfiniteQueryObserverResult,
   MutateOptions,
   MutationStatus,
+  MutationKey,
   QueryObserverOptions,
   QueryObserverResult,
   QueryKey,
+  MutationFunction,
 } from '../core/types'
 
 export interface UseBaseQueryOptions<
@@ -74,8 +76,11 @@ export interface UseMutationOptions<
   TVariables = void,
   TContext = unknown
 > {
-  mutationKey?: string | unknown[]
-  onMutate?: (variables: TVariables) => Promise<TContext> | Promise<undefined> | TContext | undefined
+  mutationFn?: MutationFunction<TData, TVariables>
+  mutationKey?: MutationKey
+  onMutate?: (
+    variables: TVariables
+  ) => Promise<TContext> | Promise<undefined> | TContext | undefined
   onSuccess?: (
     data: TData,
     variables: TVariables,


Test patch:
diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml
index d8ef950e2..c4bab97bb 100644
--- a/.github/workflows/test-and-publish.yml
+++ b/.github/workflows/test-and-publish.yml
@@ -16,10 +16,10 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        node: [10, 12, 14]
+        node: [10, 12, 14, 16]
     steps:
       - uses: actions/checkout@v2
-      - uses: actions/setup-node@v1
+      - uses: actions/setup-node@v2
         with:
           node-version: ${{ matrix.node }}
       - name: Install dependencies
@@ -34,7 +34,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v2
-      - uses: actions/setup-node@v1
+      - uses: actions/setup-node@v2
         with:
           node-version: 14
           registry-url: https://registry.npmjs.org/
diff --git a/src/core/tests/query.test.tsx b/src/core/tests/query.test.tsx
index 6b118925a..a9883c205 100644
--- a/src/core/tests/query.test.tsx
+++ b/src/core/tests/query.test.tsx
@@ -447,4 +447,29 @@ describe('query', () => {
     await sleep(100)
     expect(queryCache.find(key)).toBeDefined()
   })
+
+  test('should return proper count of observers', async () => {
+    const key = queryKey()
+    const options = { queryKey: key, queryFn: async () => 'data' }
+    const observer = new QueryObserver(queryClient, options)
+    const observer2 = new QueryObserver(queryClient, options)
+    const observer3 = new QueryObserver(queryClient, options)
+    const query = queryCache.find(key)
+
+    expect(query?.getObserversCount()).toEqual(0)
+
+    const unsubscribe1 = observer.subscribe()
+    const unsubscribe2 = observer2.subscribe()
+    const unsubscribe3 = observer3.subscribe()
+    expect(query?.getObserversCount()).toEqual(3)
+
+    unsubscribe3()
+    expect(query?.getObserversCount()).toEqual(2)
+
+    unsubscribe2()
+    expect(query?.getObserversCount()).toEqual(1)
+
+    unsubscribe1()
+    expect(query?.getObserversCount()).toEqual(0)
+  })
 })
diff --git a/src/react/tests/suspense.test.tsx b/src/react/tests/suspense.test.tsx
index a52af6602..13077a4c3 100644
--- a/src/react/tests/suspense.test.tsx
+++ b/src/react/tests/suspense.test.tsx
@@ -72,19 +72,20 @@ describe("useQuery's in Suspense mode", () => {
     const states: UseInfiniteQueryResult<number>[] = []
 
     function Page() {
+      const [multiplier, setMultiplier] = React.useState(1)
       const state = useInfiniteQuery(
-        key,
-        ({ pageParam = 0 }) => Number(pageParam),
+        `${key}_${multiplier}`,
+        ({ pageParam = 1 }) => Number(pageParam * multiplier),
         {
           suspense: true,
           getNextPageParam: lastPage => lastPage + 1,
         }
       )
       states.push(state)
-      return null
+      return <button onClick={() => setMultiplier(2)}>next</button>
     }
 
-    renderWithClient(
+    const rendered = renderWithClient(
       queryClient,
       <React.Suspense fallback="loading">
         <Page />
@@ -95,7 +96,16 @@ describe("useQuery's in Suspense mode", () => {
 
     expect(states.length).toBe(1)
     expect(states[0]).toMatchObject({
-      data: { pages: [0], pageParams: [undefined] },
+      data: { pages: [1], pageParams: [undefined] },
+      status: 'success',
+    })
+
+    fireEvent.click(rendered.getByText('next'))
+    await sleep(10)
+
+    expect(states.length).toBe(3)
+    expect(states[2]).toMatchObject({
+      data: { pages: [2], pageParams: [undefined] },
       status: 'success',
     })
   })
@@ -152,14 +162,12 @@ describe("useQuery's in Suspense mode", () => {
     fireEvent.click(rendered.getByLabelText('toggle'))
     await waitFor(() => rendered.getByText('rendered'))
 
-    // @ts-expect-error
-    expect(queryCache.find(key)?.observers.length).toBe(1)
+    expect(queryCache.find(key)?.getObserversCount()).toBe(1)
 
     fireEvent.click(rendered.getByLabelText('toggle'))
 
     expect(rendered.queryByText('rendered')).toBeNull()
-    // @ts-expect-error
-    expect(queryCache.find(key)?.observers.length).toBe(0)
+    expect(queryCache.find(key)?.getObserversCount()).toBe(0)
   })
 
   it('should call onSuccess on the first successful call', async () => {
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index 86231960c..dc8753251 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -16,6 +16,7 @@ import {
   QueryClient,
   UseQueryResult,
   QueryCache,
+  QueryFunction,
   QueryFunctionContext,
 } from '../..'
 
@@ -74,6 +75,20 @@ describe('useQuery', () => {
       })
       expectType<string | undefined>(fromGenericOptionsQueryFn.data)
       expectType<unknown>(fromGenericOptionsQueryFn.error)
+
+      type MyData = number
+      type MyQueryKey = readonly ['my-data', number]
+
+      const getMyData: QueryFunction<MyData, MyQueryKey> = async ({
+        queryKey: [, n],
+      }) => {
+        return n + 42
+      }
+
+      useQuery({
+        queryKey: ['my-data', 100],
+        queryFn: getMyData,
+      })
     }
   })