Repository: zeit/swr (ts)

Issue:
Setting max retryCount on error
Is there a way to add a max retryCount on error without overriding the entire onErrorRetry? I think it'd be nice to be able to keep the very reasonable default behaviour of exponential backoff and stopping on ```!isDocumentVisible()```, but be able to tweak and limit max tries via config.

What do you think?

Pull request description:
add errorRetryCount config option
Add errorRetryCount config option to limit error retries.

closes #276 

Patch:
diff --git a/README.md b/README.md
index 0b7abcb..8f96667 100644
--- a/README.md
+++ b/README.md
@@ -90,7 +90,7 @@ npm install swr
 ### API
 
 ```js
-const { data, error, isValidating, revalidate } = useSWR(key, fetcher, options)
+const { data, error, isValidating, mutate } = useSWR(key, fetcher, options)
 ```
 
 #### Parameters
@@ -103,7 +103,7 @@ const { data, error, isValidating, revalidate } = useSWR(key, fetcher, options)
 - `data`: data for the given key resolved by `fetcher` (or undefined if not loaded)  
 - `error`: error thrown by `fetcher` (or undefined)  
 - `isValidating`: if there's a request or revalidation loading  
-- `revalidate`: function to trigger the validation manually
+- `mutate`: function to mutate the cached data
 
 #### Options
 
@@ -120,6 +120,7 @@ const { data, error, isValidating, revalidate } = useSWR(key, fetcher, options)
 - `focusThrottleInterval = 5000`: only revalidate once during a time span
 - `loadingTimeout = 3000`: timeout to trigger the onLoadingSlow event
 - `errorRetryInterval = 5000`: error retry interval [(details)](#error-retries)
+- `errorRetryCount`: max error retry count [(details)](#error-retries)
 - `onLoadingSlow`: callback function when a request takes too long to load (see `loadingTimeout`)
 - `onSuccess`: callback function when a request finishes successfully
 - `onError`: callback function when a request returns an error
@@ -141,6 +142,8 @@ You can also use [global configuration](#global-configuration) to provide defaul
 - [Multiple Arguments](#multiple-arguments)
 - [Manually Revalidate](#manually-revalidate)
 - [Mutation and Post Request](#mutation-and-post-request)
+- [Mutate Based on Current Data](#mutate-based-on-current-data)
+- [Returned Data from Mutate](#returned-data-from-mutate)
 - [SSR with Next.js](#ssr-with-nextjs)
 - [Suspense Mode](#suspense-mode)
 - [Error Retries](#error-retries)
@@ -289,13 +292,13 @@ Dan Abramov explains dependencies very well in [this blog post](https://overreac
 ### Manually Revalidate
 
 You can broadcast a revalidation message globally to all SWRs with the same key by calling
-`trigger(key)`.
+`mutate(key)`.
 
 This example shows how to automatically refetch the login info (e.g.: inside `<Profile/>`) 
 when the user clicks the “Logout” button.
 
 ```js
-import useSWR, { trigger } from 'swr'
+import useSWR, { mutate } from 'swr'
 
 function App () {
   return (
@@ -306,7 +309,7 @@ function App () {
         document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
 
         // tell all SWRs with this key to revalidate
-        trigger('/api/user')
+        mutate('/api/user')
       }}>
         Logout
       </button>
@@ -356,6 +359,33 @@ mutate('/api/user', updateUser(newUser)) // `updateUser` is a Promise of the req
                                          // which returns the updated document
 ```
 
+### Mutate Based on Current Data
+
+In many cases, you are receiving a single value back from your API and want to update a list of them.
+
+With `mutate`, you can pass an async function which will receive the current cached value, if any, and let you return an updated document.
+
+```js
+mutate('/api/users', async users => {
+  const user = await fetcher('/api/users/1')
+  return [user, ...users.slice(1)]
+})
+```
+
+### Returned Data from Mutate
+
+Most probably, you need to data mutate used to update the cache when you passed a promise or async function.
+
+The function will returns the updated document, or throw an error, everytime you call it.
+
+```js
+try {
+  const user = await mutate('/api/user', updateUser(newUser))
+} catch (error) {
+  // Handle an error while updating the user here
+}
+```
+
 ### SSR with Next.js
 
 With the `initialData` option, you pass an initial value to the hook. It works perfectly with many SSR solutions
diff --git a/examples/optimistic-ui/pages/index.js b/examples/optimistic-ui/pages/index.js
index 9c84b5e..1a01e57 100644
--- a/examples/optimistic-ui/pages/index.js
+++ b/examples/optimistic-ui/pages/index.js
@@ -1,7 +1,7 @@
 import React from 'react'
 import fetch from '../libs/fetch'
 
-import useSWR, { mutate, trigger } from 'swr'
+import useSWR, { mutate } from 'swr'
 
 export default () => {
   const [text, setText] = React.useState('');
@@ -13,14 +13,12 @@ export default () => {
     // the fetch below could fail, in that case the UI will
     // be in an incorrect state
     mutate('/api/data', [...data, text], false)
-    // send text to the API
-    await fetch('/api/data', {
+    // then we send the request to the API and let mutate
+    // update the data with the API response
+    mutate('/api/data', await fetch('/api/data', {
       method: 'POST',
       body: JSON.stringify({ text })
-    })
-    // to revalidate the data and ensure is not incorrect
-    // we trigger a revalidation of the data
-    trigger('/api/data')
+    }))
     setText('')
   }
 
diff --git a/src/config.ts b/src/config.ts
index 0ad426f..6f74219 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -43,6 +43,10 @@ function onErrorRetry(
     return
   }
 
+  if (config.errorRetryCount && opts.retryCount > config.errorRetryCount) {
+    return
+  }
+
   // exponential backoff
   const count = Math.min(opts.retryCount || 0, 8)
   const timeout =
diff --git a/src/types.ts b/src/types.ts
index eb080eb..41d1832 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -5,10 +5,10 @@ export interface ConfigInterface<
   Fn extends fetcherFn<Data> = fetcherFn<Data>
 > {
   errorRetryInterval?: number
+  errorRetryCount?: number
   loadingTimeout?: number
   focusThrottleInterval?: number
   dedupingInterval?: number
-
   refreshInterval?: number
   refreshWhenHidden?: boolean
   refreshWhenOffline?: boolean
@@ -58,11 +58,12 @@ export type triggerInterface = (
   key: keyInterface,
   shouldRevalidate?: boolean
 ) => void
+type mutateCallback<Data = any> = (currentValue: Data) => Data
 export type mutateInterface<Data = any> = (
   key: keyInterface,
-  data: Data | Promise<Data>,
+  data?: Data | Promise<Data> | mutateCallback<Data>,
   shouldRevalidate?: boolean
-) => void
+) => Promise<Data | undefined>
 export type broadcastStateInterface<Data = any, Error = any> = (
   key: string,
   data: Data,
@@ -72,6 +73,10 @@ export type responseInterface<Data, Error> = {
   data?: Data
   error?: Error
   revalidate: () => Promise<boolean>
+  mutate: (
+    data?: Data | Promise<Data> | mutateCallback<Data>,
+    shouldRevalidate?: boolean
+  ) => Promise<Data | undefined>
   isValidating: boolean
 }
 export type revalidateType = (
diff --git a/src/use-swr-pages.tsx b/src/use-swr-pages.tsx
index 9affe92..5e1b06a 100644
--- a/src/use-swr-pages.tsx
+++ b/src/use-swr-pages.tsx
@@ -155,7 +155,8 @@ export function useSWRPages<OffsetType = any, Data = any, Error = any>(
             data: swr.data,
             error: swr.error,
             revalidate: swr.revalidate,
-            isValidating: swr.isValidating
+            isValidating: swr.isValidating,
+            mutate: swr.mutate
           }
           return _swrs
         })
diff --git a/src/use-swr.ts b/src/use-swr.ts
index 05a1c4d..566d4fb 100644
--- a/src/use-swr.ts
+++ b/src/use-swr.ts
@@ -67,6 +67,8 @@ const getKeyArgs = key => {
   return [key, args]
 }
 
+const NO_DEDUPE = false
+
 const trigger: triggerInterface = (_key, shouldRevalidate = true) => {
   const [key] = getKeyArgs(_key)
   if (!key) return
@@ -76,7 +78,7 @@ const trigger: triggerInterface = (_key, shouldRevalidate = true) => {
     const currentData = cacheGet(key)
     const currentError = cacheGet(getErrorKey(key))
     for (let i = 0; i < updaters.length; ++i) {
-      updaters[i](shouldRevalidate, currentData, currentError, true)
+      updaters[i](shouldRevalidate, currentData, currentError, NO_DEDUPE)
     }
   }
 }
@@ -90,16 +92,30 @@ const broadcastState: broadcastStateInterface = (key, data, error) => {
   }
 }
 
-const mutate: mutateInterface = async (_key, _data, shouldRevalidate) => {
+const mutate: mutateInterface = async (
+  _key,
+  _data,
+  shouldRevalidate = true
+) => {
   const [key] = getKeyArgs(_key)
   if (!key) return
 
+  // if there is no new data, call revalidate against the key
+  if (typeof _data === 'undefined') return trigger(_key, shouldRevalidate)
+
   // update timestamp
   MUTATION_TS[key] = Date.now() - 1
 
   let data, error
 
-  if (_data && typeof _data.then === 'function') {
+  if (_data && typeof _data === 'function') {
+    // `_data` is a function, call it passing current cache value
+    try {
+      data = await _data(cacheGet(key))
+    } catch (err) {
+      error = err
+    }
+  } else if (_data && typeof _data.then === 'function') {
     // `_data` is a promise
     try {
       data = await _data
@@ -108,12 +124,6 @@ const mutate: mutateInterface = async (_key, _data, shouldRevalidate) => {
     }
   } else {
     data = _data
-
-    if (typeof shouldRevalidate === 'undefined') {
-      // if it's a sync mutation, we trigger the revalidation by default
-      // because in most cases it's a local mutation
-      shouldRevalidate = true
-    }
   }
 
   if (typeof data !== 'undefined') {
@@ -125,9 +135,13 @@ const mutate: mutateInterface = async (_key, _data, shouldRevalidate) => {
   const updaters = CACHE_REVALIDATORS[key]
   if (updaters) {
     for (let i = 0; i < updaters.length; ++i) {
-      updaters[i](!!shouldRevalidate, data, error, true)
+      updaters[i](!!shouldRevalidate, data, error, NO_DEDUPE)
     }
   }
+
+  // throw error or return data to be used by caller of mutate
+  if (error) throw error
+  return data
 }
 
 function useSWR<Data = any, Error = any>(
@@ -217,6 +231,13 @@ function useSWR<Data = any, Error = any>(
   const unmountedRef = useRef(false)
   const keyRef = useRef(key)
 
+  const boundMutate: responseInterface<Data, Error>['mutate'] = useCallback(
+    (data, shouldRevalidate) => {
+      return mutate(key, data, shouldRevalidate)
+    },
+    [key]
+  )
+
   // start a revalidation
   const revalidate = useCallback(
     async (
@@ -586,6 +607,7 @@ function useSWR<Data = any, Error = any>(
       error: latestError,
       data: latestData,
       revalidate,
+      mutate: boundMutate,
       isValidating: stateRef.current.isValidating
     }
   }
@@ -593,7 +615,10 @@ function useSWR<Data = any, Error = any>(
   // define returned state
   // can be memorized since the state is a ref
   return useMemo(() => {
-    const state = { revalidate } as responseInterface<Data, Error>
+    const state = { revalidate, mutate: boundMutate } as responseInterface<
+      Data,
+      Error
+    >
     Object.defineProperties(state, {
       error: {
         // `key` might be changed in the upcoming hook re-render,


Test patch:
diff --git a/test/use-swr.test.tsx b/test/use-swr.test.tsx
index 735a992..e8c28f9 100644
--- a/test/use-swr.test.tsx
+++ b/test/use-swr.test.tsx
@@ -8,6 +8,7 @@ import {
 import React, { ReactNode, Suspense, useEffect, useState } from 'react'
 
 import useSWR, { mutate, SWRConfig, trigger } from '../src'
+import { cacheSet } from '../src/config'
 
 class ErrorBoundary extends React.Component<{ fallback: ReactNode }> {
   state = { hasError: false }
@@ -698,6 +699,36 @@ describe('useSWR - error', () => {
   })
 })
 
+it('should trigger limited error retries if errorRetryCount exists', async () => {
+  let count = 0
+  function Page() {
+    const { data, error } = useSWR(
+      'error-5',
+      () => {
+        return new Promise((_, rej) =>
+          setTimeout(() => rej(new Error('error: ' + count++)), 100)
+        )
+      },
+      {
+        errorRetryCount: 1,
+        errorRetryInterval: 50,
+        dedupingInterval: 0
+      }
+    )
+    if (error) return <div>{error.message}</div>
+    return <div>hello, {data}</div>
+  }
+  const { container } = render(<Page />)
+
+  expect(container.firstChild.textContent).toMatchInlineSnapshot(`"hello, "`)
+  await waitForDomChange({ container })
+  expect(container.firstChild.textContent).toMatchInlineSnapshot(`"error: 0"`)
+  await act(() => new Promise(res => setTimeout(res, 210))) // retry
+  expect(container.firstChild.textContent).toMatchInlineSnapshot(`"error: 1"`)
+  await act(() => new Promise(res => setTimeout(res, 210))) // retry
+  expect(container.firstChild.textContent).toMatchInlineSnapshot(`"error: 1"`)
+})
+
 describe('useSWR - focus', () => {
   afterEach(cleanup)
 
@@ -775,6 +806,29 @@ describe('useSWR - local mutation', () => {
     expect(container.firstChild.textContent).toMatchInlineSnapshot(`"data: 1"`)
   })
 
+  it('should trigger revalidation programmatically with a dedupingInterval', async () => {
+    let value = 0
+
+    function Page() {
+      const { data } = useSWR('dynamic-12', () => value++, {
+        dedupingInterval: 2000
+      })
+      return <div>data: {data}</div>
+    }
+    const { container } = render(<Page />)
+
+    // hydration
+    expect(container.firstChild.textContent).toMatchInlineSnapshot(`"data: "`)
+    await waitForDomChange({ container }) // mount
+    expect(container.firstChild.textContent).toMatchInlineSnapshot(`"data: 0"`)
+    await act(() => {
+      // trigger revalidation
+      trigger('dynamic-12')
+      return new Promise(res => setTimeout(res, 1))
+    })
+    expect(container.firstChild.textContent).toMatchInlineSnapshot(`"data: 1"`)
+  })
+
   it('should mutate the cache and revalidate', async () => {
     let value = 0
 
@@ -798,6 +852,29 @@ describe('useSWR - local mutation', () => {
     expect(container.firstChild.textContent).toMatchInlineSnapshot(`"data: 1"`)
   })
 
+  it('should mutate the cache and revalidate with a dedupingInterval', async () => {
+    let value = 0
+
+    function Page() {
+      const { data } = useSWR('dynamic-13', () => value++, {
+        dedupingInterval: 2000
+      })
+      return <div>data: {data}</div>
+    }
+    const { container } = render(<Page />)
+
+    // hydration
+    expect(container.firstChild.textContent).toMatchInlineSnapshot(`"data: "`)
+    await waitForDomChange({ container }) // mount
+    expect(container.firstChild.textContent).toMatchInlineSnapshot(`"data: 0"`)
+    await act(() => {
+      // mutate and revalidate
+      mutate('dynamic-13', 'mutate')
+      return new Promise(res => setTimeout(res, 1))
+    })
+    expect(container.firstChild.textContent).toMatchInlineSnapshot(`"data: 1"`)
+  })
+
   it('should mutate the cache and revalidate in async', async () => {
     function Page() {
       const { data } = useSWR(
@@ -846,13 +923,82 @@ describe('useSWR - local mutation', () => {
       // mutate and revalidate
       return mutate(
         'mutate-1',
-        new Promise(res => setTimeout(() => res(999), 100))
+        new Promise(res => setTimeout(() => res(999), 100)),
+        false
       )
     })
     await act(() => new Promise(res => setTimeout(res, 110)))
     expect(container.textContent).toMatchInlineSnapshot(`"data: 999"`)
   })
 
+  it('should trigger on mutation without data', async () => {
+    let value = 0
+
+    function Page() {
+      const { data } = useSWR('dynamic-14', () => value++, {
+        dedupingInterval: 0
+      })
+      return <div>data: {data}</div>
+    }
+    const { container } = render(<Page />)
+
+    // hydration
+    expect(container.firstChild.textContent).toMatchInlineSnapshot(`"data: "`)
+    await waitForDomChange({ container }) // mount
+    expect(container.firstChild.textContent).toMatchInlineSnapshot(`"data: 0"`)
+    await act(() => {
+      // trigger revalidation
+      mutate('dynamic-14')
+      return new Promise(res => setTimeout(res, 1))
+    })
+    expect(container.firstChild.textContent).toMatchInlineSnapshot(`"data: 1"`)
+  })
+
+  it('should call function as data passing current cached value', async () => {
+    // prefill cache with data
+    cacheSet('dynamic-15', 'cached data')
+    const callback = jest.fn()
+    await mutate('dynamic-15', callback)
+    expect(callback).toHaveBeenCalledWith('cached data')
+  })
+
+  it('should return results of the mutation', async () => {
+    // returns the data if promise resolved
+    expect(mutate('dynamic-16', Promise.resolve('data'))).resolves.toBe('data')
+
+    // throw the error if promise rejected
+    expect(
+      mutate('dynamic-16', Promise.reject(new Error('error')))
+    ).rejects.toBeInstanceOf(Error)
+  })
+
+  it('should get bound mutate from useSWR', async () => {
+    function Page() {
+      // eslint-disable-next-line no-shadow
+      const { data, mutate: boundMutate } = useSWR(
+        'dynamic-17',
+        () => 'fetched'
+      )
+      return (
+        <div onClick={() => boundMutate('mutated', false)}>data: {data}</div>
+      )
+    }
+    const { container } = render(<Page />)
+
+    // hydration
+    expect(container.firstChild.textContent).toMatchInlineSnapshot(`"data: "`)
+    await waitForDomChange({ container }) // mount
+    expect(container.firstChild.textContent).toMatchInlineSnapshot(
+      `"data: fetched"`
+    )
+    // call bound mutate
+    fireEvent.click(container.firstElementChild)
+    // expect new updated value
+    expect(container.firstChild.textContent).toMatchInlineSnapshot(
+      `"data: mutated"`
+    )
+  })
+
   it('should ignore in flight requests when mutating', async () => {
     // set it to 1
     mutate('mutate-2', 1)