Repository: tannerlinsley/react-query
Pull request #1397 (2020-12-13 14:33:52)

feat: resetQueries refetches active queries
This is a follow up to #1375 that, I think, makes `resetQueries` more useful and less surprising. 

vercel[bot]: @aaronjensen is attempting to deploy a commit to a Personal Account owned by @tannerlinsley on [Vercel](https://vercel.com).

@tannerlinsley first needs to [authorize it](https://vercel.com/git/authorize?type=github&job=%7B%22headInfo%22%3A%7B%22sha%22%3A%22934d1bd23befac50d6258770c4f390f40231ef76%22%7D%2C%22id%22%3A%22QmVnstUni4Su8HCLfrevitNJZcEjXF3GSav5qtXPMtmqkq%22%2C%22org%22%3A%22tannerlinsley%22%2C%22prId%22%3A1397%2C%22repo%22%3A%22react-query%22%7D).



Patch:
diff --git a/docs/src/manifests/manifest.json b/docs/src/manifests/manifest.json
index a7eb41671..08f83c9a5 100644
--- a/docs/src/manifests/manifest.json
+++ b/docs/src/manifests/manifest.json
@@ -293,6 +293,17 @@
         }
       ]
     },
+    {
+      "title": "Plugins",
+      "heading": true,
+      "routes": [
+        {
+          "title": "Persist - LocalStorage (Experimental)",
+          "path": "/plugins/persist-localstorage",
+          "editUrl": "/plugins/persist-localstorage.md"
+        }
+      ]
+    },
     {
       "title": "API Reference",
       "heading": true,
diff --git a/docs/src/pages/plugins/persist-localstorage.md b/docs/src/pages/plugins/persist-localstorage.md
new file mode 100644
index 000000000..5b627b613
--- /dev/null
+++ b/docs/src/pages/plugins/persist-localstorage.md
@@ -0,0 +1,83 @@
+---
+id: persist-localstorage
+title: Persist - LocalStorage (Experimental)
+---
+
+> VERY IMPORTANT: This plugin is currently in an experimental stage. This means that breaking changes will happen in minor and patch releases. Use at your own risk. If you choose to rely on this in production in an experimental stage, please lock your version to a patch-level version to avoid unexpected breakages.
+
+## Installation
+
+This plugin comes packaged with `react-query` and is available under the `react-query/persist-localstorage-experimental` import.
+
+## Usage
+
+Import the `persistWithLocalStorage` function, and pass it your `QueryClient` instance!
+
+```js
+import { persistWithLocalStorage } from 'react-query/persist-localstorage-experimental'
+
+const queryClient = new QueryClient()
+
+persistWithLocalStorage(queryClient)
+```
+
+## How does it work?
+
+As you use your application:
+
+- When your query/mutation cache is updated, it will be dehydrated and serialized into localstorage. **By default**, this action is throttled to happen at most every 1 second to save on potentially expensive writes to localstorage, but can be customized as you see fit.
+
+When you reload/bootstrap your app:
+
+- Attempts to load a previously persisted dehydrated query/mutation cache from localstorage
+- If a cache is found that is older than the `maxAge` (which by default is 24 hours), it will be discarded. This can be customized as you see fit.
+
+## Cache Busting
+
+Sometimes you may make changes to your application or data that immediately invalidate any and all cached data. If and when this happens, you can pass a `buster` string option to `persistWithLocalStorage`, and if the cache that is found does not also have that buster string, it will be discarded.
+
+```js
+persistWithLocalStorage(queryClient, { buster: buildHash })
+```
+
+## API
+
+### `persistWithLocalStorage`
+
+Pass this function a `QueryClient` instance to persist it to localstorage.
+
+```js
+persistWithLocalStorage(queryClient, options)
+```
+
+### `Options`
+
+An optional object of options:
+
+```js
+interface Options {
+  /** The key to use when storing the cache to localstorage */
+  localStorageKey?: string
+  /** To avoid localstorage spamming,
+   * pass a time in ms to throttle saving the cache to disk */
+  throttleTime?: number
+  /** The max-allowed age of the cache.
+   * If a persisted cache is found that is older than this
+   * time, it will be discarded */
+  maxAge?: number
+  /** A unique string that can be used to forcefully
+   * invalidate existing caches if they do not share the same buster string */
+  buster?: string
+}
+```
+
+The default options are:
+
+```js
+{
+  localStorageKey = `REACT_QUERY_OFFLINE_CACHE`,
+  throttleTime = 1000,
+  maxAge = 1000 * 60 * 60 * 24, // 24 hours
+  buster = '',
+}
+```
diff --git a/docs/src/pages/reference/QueryClient.md b/docs/src/pages/reference/QueryClient.md
index 4e34813ae..f3e0694e6 100644
--- a/docs/src/pages/reference/QueryClient.md
+++ b/docs/src/pages/reference/QueryClient.md
@@ -284,7 +284,7 @@ property/state of the query.
 This will notify subscribers &mdash; unlike `clear`, which removes all
 subscribers &mdash; and reset the query to its pre-loaded state &mdash; unlike
 `invalidateQueries`. If a query has `initialData`, the query's data will be
-reset to that.
+reset to that. If a query is active, it will be refetched.
 
 ```js
 queryClient.resetQueries(queryKey, { exact: true })
@@ -294,10 +294,13 @@ queryClient.resetQueries(queryKey, { exact: true })
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
 - `filters?: QueryFilters`: [Query Filters](../guides/query-filters)
+- `resetOptions?: ResetOptions`:
+  - `throwOnError?: boolean`
+    - When set to `true`, this method will throw if any of the query refetch tasks fail.
 
 **Returns**
 
-This method does not return anything
+This method returns a promise that resolves when all active queries have been refetched.
 
 ## `queryClient.isFetching`
 
diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts
index af2a30074..44b490490 100644
--- a/src/core/queryClient.ts
+++ b/src/core/queryClient.ts
@@ -20,6 +20,7 @@ import type {
   QueryObserverOptions,
   QueryOptions,
   RefetchOptions,
+  ResetOptions,
 } from './types'
 import type { QueryState, SetDataOptions } from './query'
 import { QueryCache } from './queryCache'
@@ -132,15 +133,22 @@ export class QueryClient {
     })
   }
 
-  resetQueries(filters?: QueryFilters): void
-  resetQueries(queryKey?: QueryKey, filters?: QueryFilters): void
-  resetQueries(arg1?: QueryKey | QueryFilters, arg2?: QueryFilters): void {
-    const [filters] = parseFilterArgs(arg1, arg2)
+  resetQueries(filters?: QueryFilters, options?: ResetOptions): Promise<void>
+  resetQueries(queryKey?: QueryKey, filters?: QueryFilters, options?: ResetOptions): Promise<void>
+  resetQueries(arg1?: QueryKey | QueryFilters, arg2?: QueryFilters | ResetOptions, arg3?: ResetOptions): Promise<void> {
+    const [filters, options] = parseFilterArgs(arg1, arg2, arg3)
     const queryCache = this.queryCache
-    notifyManager.batch(() => {
+
+    const refetchFilters: QueryFilters = {
+      ...filters,
+      active: true,
+    }
+
+    return notifyManager.batch(() => {
       queryCache.findAll(filters).forEach(query => {
         query.reset()
       })
+      return this.refetchQueries(refetchFilters, options)
     })
   }
 
diff --git a/src/core/types.ts b/src/core/types.ts
index 2bfe1e27a..1a785cb4c 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -214,6 +214,10 @@ export interface InvalidateOptions {
   throwOnError?: boolean
 }
 
+export interface ResetOptions {
+  throwOnError?: boolean
+}
+
 export interface FetchNextPageOptions extends ResultOptions {
   pageParam?: unknown
 }


Test patch:
diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx
index 1470d7d7e..8fa80a6fd 100644
--- a/src/core/tests/queryCache.test.tsx
+++ b/src/core/tests/queryCache.test.tsx
@@ -998,6 +998,33 @@ describe('queryCache', () => {
     expect(state?.data).toEqual('initial')
   })
 
+  test('resetQueries should refetch all active queries', async () => {
+    const key1 = queryKey()
+    const key2 = queryKey()
+    const queryFn1 = jest.fn()
+    const queryFn2 = jest.fn()
+    const testCache = new QueryCache()
+    const testClient = new QueryClient({ queryCache: testCache })
+    const observer1 = new QueryObserver(testClient, {
+      queryKey: key1,
+      queryFn: queryFn1,
+      enabled: true,
+    })
+    const observer2 = new QueryObserver(testClient, {
+      queryKey: key2,
+      queryFn: queryFn2,
+      enabled: false,
+    })
+    observer1.subscribe()
+    observer2.subscribe()
+    await testClient.resetQueries()
+    observer2.destroy()
+    observer1.destroy()
+    testCache.clear()
+    expect(queryFn1).toHaveBeenCalledTimes(2)
+    expect(queryFn2).toHaveBeenCalledTimes(0)
+  })
+
   test('find should filter correctly', async () => {
     const key = queryKey()
     const testCache = new QueryCache()
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index 6cfbf2916..6f73279e6 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -2852,4 +2852,129 @@ describe('useQuery', () => {
 
     expect(cancelFn).toHaveBeenCalled()
   })
+
+  it('should update query state and refetch when reset with resetQueries', async () => {
+    const key = queryKey()
+    const states: UseQueryResult<number>[] = []
+    let count = 0
+
+    function Page() {
+      const state = useQuery(
+        key,
+        () => {
+          count++
+          return count
+        },
+        { staleTime: Infinity }
+      )
+
+      states.push(state)
+
+      React.useEffect(() => {
+        setActTimeout(() => {
+          queryClient.resetQueries(key)
+        }, 10)
+      }, [])
+
+      return null
+    }
+
+    renderWithClient(queryClient, <Page />)
+
+    await sleep(100)
+
+    expect(states.length).toBe(4)
+    expect(states[0]).toMatchObject({
+      data: undefined,
+      isLoading: true,
+      isFetching: true,
+      isSuccess: false,
+      isStale: true,
+    })
+    expect(states[1]).toMatchObject({
+      data: 1,
+      isLoading: false,
+      isFetching: false,
+      isSuccess: true,
+      isStale: false,
+    })
+    expect(states[2]).toMatchObject({
+      data: undefined,
+      isLoading: true,
+      isFetching: true,
+      isSuccess: false,
+      isStale: true,
+    })
+    expect(states[3]).toMatchObject({
+      data: 2,
+      isLoading: false,
+      isFetching: false,
+      isSuccess: true,
+      isStale: false,
+    })
+  })
+
+  it('should update query state and not refetch when resetting a disabled query with resetQueries', async () => {
+    const key = queryKey()
+    const states: UseQueryResult<number>[] = []
+    let count = 0
+
+    function Page() {
+      const state = useQuery(
+        key,
+        () => {
+          count++
+          return count
+        },
+        { staleTime: Infinity, enabled: false }
+      )
+
+      states.push(state)
+
+      React.useEffect(() => {
+        setActTimeout(() => {
+          state.refetch()
+        }, 0)
+        setActTimeout(() => {
+          queryClient.resetQueries(key)
+        }, 50)
+      }, [])
+
+      return null
+    }
+
+    renderWithClient(queryClient, <Page />)
+
+    await sleep(100)
+
+    expect(states.length).toBe(4)
+    expect(states[0]).toMatchObject({
+      data: undefined,
+      isLoading: false,
+      isFetching: false,
+      isSuccess: false,
+      isStale: true,
+    })
+    expect(states[1]).toMatchObject({
+      data: undefined,
+      isLoading: true,
+      isFetching: true,
+      isSuccess: false,
+      isStale: true,
+    })
+    expect(states[2]).toMatchObject({
+      data: 1,
+      isLoading: false,
+      isFetching: false,
+      isSuccess: true,
+      isStale: false,
+    })
+    expect(states[3]).toMatchObject({
+      data: undefined,
+      isLoading: false,
+      isFetching: false,
+      isSuccess: false,
+      isStale: true,
+    })
+  })
 })