Repository: tannerlinsley/react-query (ts)

Issue:
persistWithLocalStorage - Failed to execute 'setItem' on 'Storage'  REACT_QUERY_OFFLINE_CACHE exceeded the quota
**Describe the bug**
After loading our app and browsing our a few pages (which make useQuery calls) we get a `localStorage` error.

```
Uncaught DOMException: Failed to execute 'setItem' on 'Storage': Setting the value of 'REACT_QUERY_OFFLINE_CACHE' exceeded the quota.
```

These are the options I am using for the query config, essentially using [defaults options](https://react-query.tanstack.com/plugins/persist-localstorage#options) where throttleTime is `1000` 

```ts
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      useErrorBoundary: true,
      refetchOnWindowFocus: false,
      cacheTime: 1000 * 60 * 60 * 24,
    },
  },
});
persistWithLocalStorage(queryClient); 
```



**To Reproduce**
Steps to reproduce the behavior:
1. Use the app with [persistWithLocalStorage](https://react-query.tanstack.com/plugins/persist-localstorage#usage) enabled

**Expected behavior**
Should't error, we may need a new default value for the throttle time in options




**Screenshots**
![image](https://user-images.githubusercontent.com/6743796/106076497-8370db00-60c4-11eb-819d-bf647d191b18.png)

Pull request description:
try to clean the old persistented data when storage full
fix #1701
when storage exceeded the quota.

1. try to clean the mutations (from old to new by creating time)
2. try to clean the queries (from old to new by updating time)

Patch:
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 000000000..66839dbcf
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,6 @@
+coverage:
+  status:
+    project:
+      default:
+        target: 90% 
+        threshold: 1%
\ No newline at end of file
diff --git a/docs/src/pages/guides/placeholder-query-data.md b/docs/src/pages/guides/placeholder-query-data.md
index 7de8eb712..e9c2d2437 100644
--- a/docs/src/pages/guides/placeholder-query-data.md
+++ b/docs/src/pages/guides/placeholder-query-data.md
@@ -56,4 +56,4 @@ function Todo({ blogPostId }) {
 
 ## Further reading
 
-For a comparison between `Placholder Data` and `Initial Data`, have a look at the [Community Resources](../community/tkdodos-blog#9-placeholder-and-initial-data-in-react-query).
+For a comparison between `Placeholder Data` and `Initial Data`, have a look at the [Community Resources](../community/tkdodos-blog#9-placeholder-and-initial-data-in-react-query).
diff --git a/docs/src/pages/guides/query-cancellation.md b/docs/src/pages/guides/query-cancellation.md
index bde529e6e..dd37dee0f 100644
--- a/docs/src/pages/guides/query-cancellation.md
+++ b/docs/src/pages/guides/query-cancellation.md
@@ -9,6 +9,8 @@ React Query provides each query function with an [`AbortSignal` instance](https:
 
 The `AbortController` API is available in [most runtime environments](https://developer.mozilla.org/docs/Web/API/AbortController#browser_compatibility), but if the runtime environment does not support it then the query function will receive `undefined` in its place. You may choose to polyfill the `AbortController` API if you wish, there are [several available](https://www.npmjs.com/search?q=abortcontroller%20polyfill).
 
+**NOTE:** This feature was introduced at version `3.30.0`. If you are using an older version, you will need to either upgrade (recommended) or use the [old `cancel` function](#old-cancel-function).
+
 ## Using `fetch`
 
 ```js
diff --git a/jest.config.js b/jest.config.js
index 89507372d..2bb4897dd 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -2,7 +2,7 @@ module.exports = {
   collectCoverage: true,
   coverageReporters: ['json', 'lcov', 'text', 'clover', 'text-summary'],
   setupFilesAfterEnv: ['./jest.setup.js'],
-  testMatch: ['<rootDir>/src/**/*.test.tsx'],
+  testMatch: ['<rootDir>/src/**/*.test.{tsx,ts}'],
   testPathIgnorePatterns: ['<rootDir>/types/'],
   moduleNameMapper: {
     'react-query': '<rootDir>/src/index.ts',
diff --git a/src/createWebStoragePersistor-experimental/index.ts b/src/createWebStoragePersistor-experimental/index.ts
index 130f5d9f2..5c4b8a0de 100644
--- a/src/createWebStoragePersistor-experimental/index.ts
+++ b/src/createWebStoragePersistor-experimental/index.ts
@@ -28,10 +28,47 @@ export function createWebStoragePersistor({
   serialize = JSON.stringify,
   deserialize = JSON.parse,
 }: CreateWebStoragePersistorOptions): Persistor {
+  //try to save data to storage
+  function trySave(persistedClient: PersistedClient) {
+    try {
+      storage.setItem(key, serialize(persistedClient))
+    } catch {
+      return false
+    }
+    return true
+  }
+
   if (typeof storage !== 'undefined') {
     return {
       persistClient: throttle(persistedClient => {
-        storage.setItem(key, serialize(persistedClient))
+        if (trySave(persistedClient) !== true) {
+          const mutations = [...persistedClient.clientState.mutations]
+          const queries = [...persistedClient.clientState.queries]
+          const client: PersistedClient = {
+            ...persistedClient,
+            clientState: { mutations, queries },
+          }
+
+          // sort queries by dataUpdatedAt (oldest first)
+          const sortedQueries = [...queries].sort(
+            (a, b) => a.state.dataUpdatedAt - b.state.dataUpdatedAt
+          )
+          // clean old queries and try to save
+          while (sortedQueries.length > 0) {
+            const oldestData = sortedQueries.shift()
+            client.clientState.queries = queries.filter(q => q !== oldestData)
+            if (trySave(client)) {
+              return // save success
+            }
+          }
+
+          // clean mutations and try to save
+          while (mutations.shift()) {
+            if (trySave(client)) {
+              return // save success
+            }
+          }
+        }
       }, throttleTime),
       restoreClient: () => {
         const cacheString = storage.getItem(key)
diff --git a/src/devtools/Explorer.tsx b/src/devtools/Explorer.tsx
index 774fe22df..b409bc2ce 100644
--- a/src/devtools/Explorer.tsx
+++ b/src/devtools/Explorer.tsx
@@ -6,7 +6,7 @@ import { styled } from './utils'
 
 export const Entry = styled('div', {
   fontFamily: 'Menlo, monospace',
-  fontSize: '0.9em',
+  fontSize: '1em',
   lineHeight: '1.7',
   outline: 'none',
   wordBreak: 'break-word',


Test patch:
diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml
index 3d5630e4c..8c2e52ac9 100644
--- a/.github/workflows/test-and-publish.yml
+++ b/.github/workflows/test-and-publish.yml
@@ -29,6 +29,8 @@ jobs:
         if: matrix.node == '16'
         env:
           BUNDLEWATCH_GITHUB_TOKEN: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}
+      - name: Upload coverage to Codecov
+        uses: codecov/codecov-action@v2
 
   publish-module:
     name: 'Publish Module to NPM'
diff --git a/src/createWebStoragePersistor-experimental/tests/storageIsFull.test.ts b/src/createWebStoragePersistor-experimental/tests/storageIsFull.test.ts
new file mode 100644
index 000000000..2b19a1d45
--- /dev/null
+++ b/src/createWebStoragePersistor-experimental/tests/storageIsFull.test.ts
@@ -0,0 +1,175 @@
+import { dehydrate, MutationCache, QueryCache, QueryClient } from '../../core'
+import { sleep } from '../../react/tests/utils'
+import { createWebStoragePersistor } from '../index'
+
+function getMockStorage(limitSize?: number) {
+  const dataSet = new Map<string, string>()
+  return ({
+    getItem(key: string): string | null {
+      const value = dataSet.get(key)
+      return value === undefined ? null : value
+    },
+
+    setItem(key: string, value: string) {
+      if (limitSize) {
+        const currentSize = Array.from(dataSet.entries()).reduce(
+          (n, d) => d[0].length + d[1].length + n,
+          0
+        )
+        if (
+          currentSize - (dataSet.get(key)?.length || 0) + value.length >
+          limitSize
+        ) {
+          throw Error(
+            ` Failed to execute 'setItem' on 'Storage': Setting the value of '${key}' exceeded the quota.`
+          )
+        }
+      }
+      return dataSet.set(key, value)
+    },
+    removeItem(key: string) {
+      return dataSet.delete(key)
+    },
+  } as any) as Storage
+}
+
+describe('createWebStoragePersistor ', () => {
+  test('basic store and recover', async () => {
+    const queryCache = new QueryCache()
+    const mutationCache = new MutationCache()
+    const queryClient = new QueryClient({ queryCache, mutationCache })
+
+    const storage = getMockStorage()
+    const webStoragePersistor = createWebStoragePersistor({
+      throttleTime: 0,
+      storage,
+    })
+
+    await queryClient.prefetchQuery('string', () => Promise.resolve('string'))
+    await queryClient.prefetchQuery('number', () => Promise.resolve(1))
+    await queryClient.prefetchQuery('boolean', () => Promise.resolve(true))
+    await queryClient.prefetchQuery('null', () => Promise.resolve(null))
+    await queryClient.prefetchQuery('array', () =>
+      Promise.resolve(['string', 0])
+    )
+
+    const persistClient = {
+      buster: 'test-buster',
+      timestamp: Date.now(),
+      clientState: dehydrate(queryClient),
+    }
+    webStoragePersistor.persistClient(persistClient)
+    await sleep(1)
+    const restoredClient = await webStoragePersistor.restoreClient()
+    expect(restoredClient).toEqual(persistClient)
+  })
+
+  test('should clean the old queries when storage full', async () => {
+    const queryCache = new QueryCache()
+    const mutationCache = new MutationCache()
+    const queryClient = new QueryClient({ queryCache, mutationCache })
+
+    const N = 2000
+    const storage = getMockStorage(N * 5) // can save 4 items;
+    const webStoragePersistor = createWebStoragePersistor({
+      throttleTime: 0,
+      storage,
+    })
+
+    await queryClient.prefetchQuery('A', () => Promise.resolve('A'.repeat(N)))
+    await sleep(1)
+    await queryClient.prefetchQuery('B', () => Promise.resolve('B'.repeat(N)))
+    await sleep(1)
+    await queryClient.prefetchQuery('C', () => Promise.resolve('C'.repeat(N)))
+    await sleep(1)
+    await queryClient.prefetchQuery('D', () => Promise.resolve('D'.repeat(N)))
+
+    await sleep(1)
+    await queryClient.prefetchQuery('E', () => Promise.resolve('E'.repeat(N)))
+
+    const persistClient = {
+      buster: 'test-limit',
+      timestamp: Date.now(),
+      clientState: dehydrate(queryClient),
+    }
+    webStoragePersistor.persistClient(persistClient)
+    await sleep(10)
+    const restoredClient = await webStoragePersistor.restoreClient()
+    expect(restoredClient?.clientState.queries.length).toEqual(4)
+    expect(
+      restoredClient?.clientState.queries.find(q => q.queryKey === 'A')
+    ).toBeUndefined()
+    expect(
+      restoredClient?.clientState.queries.find(q => q.queryKey === 'B')
+    ).not.toBeUndefined()
+
+    // update query Data
+    await queryClient.prefetchQuery('A', () => Promise.resolve('a'.repeat(N)))
+    const updatedPersistClient = {
+      buster: 'test-limit',
+      timestamp: Date.now(),
+      clientState: dehydrate(queryClient),
+    }
+    webStoragePersistor.persistClient(updatedPersistClient)
+    await sleep(10)
+    const restoredClient2 = await webStoragePersistor.restoreClient()
+    expect(restoredClient2?.clientState.queries.length).toEqual(4)
+    expect(
+      restoredClient2?.clientState.queries.find(q => q.queryKey === 'A')
+    ).toHaveProperty('state.data', 'a'.repeat(N))
+    expect(
+      restoredClient2?.clientState.queries.find(q => q.queryKey === 'B')
+    ).toBeUndefined()
+  })
+
+  test('should clean queries before mutations when storage full', async () => {
+    const queryCache = new QueryCache()
+    const mutationCache = new MutationCache()
+    const queryClient = new QueryClient({ queryCache, mutationCache })
+
+    const N = 2000
+    const storage = getMockStorage(N * 5) // can save 4 items;
+    const webStoragePersistor = createWebStoragePersistor({
+      throttleTime: 0,
+      storage,
+    })
+
+    mutationCache.build(
+      queryClient,
+      {
+        mutationKey: 'MUTATIONS',
+        mutationFn: () => Promise.resolve('M'.repeat(N)),
+      },
+      {
+        error: null,
+        context: '',
+        failureCount: 1,
+        isPaused: true,
+        status: 'success',
+        variables: '',
+        data: 'M'.repeat(N),
+      }
+    )
+    await sleep(1)
+    await queryClient.prefetchQuery('A', () => Promise.resolve('A'.repeat(N)))
+    await sleep(1)
+    await queryClient.prefetchQuery('B', () => Promise.resolve('B'.repeat(N)))
+    await queryClient.prefetchQuery('C', () => Promise.resolve('C'.repeat(N)))
+    await sleep(1)
+    await queryClient.prefetchQuery('D', () => Promise.resolve('D'.repeat(N)))
+
+    const persistClient = {
+      buster: 'test-limit-mutations',
+      timestamp: Date.now(),
+      clientState: dehydrate(queryClient),
+    }
+    webStoragePersistor.persistClient(persistClient)
+    await sleep(10)
+    const restoredClient = await webStoragePersistor.restoreClient()
+    expect(restoredClient?.clientState.mutations.length).toEqual(1)
+    expect(restoredClient?.clientState.queries.length).toEqual(3)
+    expect(
+      restoredClient?.clientState.queries.find(q => q.queryKey === 'A')
+    ).toBeUndefined()
+  })
+})