Repository: tannerlinsley/react-query (ts)

Issue:
failed queries won't continue retries when unmounting and remounting while waiting for the retry
**Describe the bug**

Encountered while seeing `strict effects` being enabled, which might mount and unmount multiple times. The situation is:

- component mounts, we fetch
- fetch errors, we wait for retry (set retryDelay to a high time)
- now, component unmounts and remounts
- this "cancels" the retry, but the component stays in fetching state

--> once the retrier continues, it sees that the retry has been cancelled and just returns the error, but in fact, it should continue to retry because a new observer has mounted.

Potential solution is to expose a `continueRetry` method, which will undo the `cancelRetry` (its just a flag that is set to `true`) so that the retrier can continue. We can invoke this when we try to fetch but are already in fetching state and thus just return the current promise.

Pull request description:
3031 continue retries
fixes #3031 

Patch:
diff --git a/src/core/query.ts b/src/core/query.ts
index c275876e9..1b5a3854a 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -380,6 +380,8 @@ export class Query<
         // Silently cancel current fetch if the user wants to cancel refetches
         this.cancel({ silent: true })
       } else if (this.promise) {
+        // make sure that retries that were potentially cancelled due to unmounts can continue
+        this.retryer?.continueRetry()
         // Return current promise if we are already fetching
         return this.promise
       }
diff --git a/src/core/retryer.ts b/src/core/retryer.ts
index d1c6e162d..07621123d 100644
--- a/src/core/retryer.ts
+++ b/src/core/retryer.ts
@@ -61,6 +61,7 @@ export function isCancelledError(value: any): value is CancelledError {
 export class Retryer<TData = unknown, TError = unknown> {
   cancel: (options?: CancelOptions) => void
   cancelRetry: () => void
+  continueRetry: () => void
   continue: () => void
   failureCount: number
   isPaused: boolean
@@ -82,6 +83,9 @@ export class Retryer<TData = unknown, TError = unknown> {
     this.cancelRetry = () => {
       cancelRetry = true
     }
+    this.continueRetry = () => {
+      cancelRetry = false
+    }
     this.continue = () => continueFn?.()
     this.failureCount = 0
     this.isPaused = false


Test patch:
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index 547d5ddf1..54b1f40a1 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -2671,6 +2671,111 @@ describe('useQuery', () => {
     consoleMock.mockRestore()
   })
 
+  it('should continue retries when observers unmount and remount while waiting for a retry (#3031)', async () => {
+    const key = queryKey()
+    const consoleMock = mockConsoleError()
+    let count = 0
+
+    function Page() {
+      const result = useQuery(
+        key,
+        async () => {
+          count++
+          await sleep(10)
+          return Promise.reject('some error')
+        },
+        {
+          retry: 2,
+          retryDelay: 100,
+        }
+      )
+
+      return (
+        <div>
+          <div>error: {result.error ?? 'null'}</div>
+          <div>failureCount: {result.failureCount}</div>
+        </div>
+      )
+    }
+
+    function App() {
+      const [show, toggle] = React.useReducer(x => !x, true)
+
+      return (
+        <div>
+          <button onClick={toggle}>{show ? 'hide' : 'show'}</button>
+          {show && <Page />}
+        </div>
+      )
+    }
+
+    const rendered = renderWithClient(queryClient, <App />)
+
+    await waitFor(() => rendered.getByText('failureCount: 1'))
+    rendered.getByRole('button', { name: /hide/i }).click()
+    rendered.getByRole('button', { name: /show/i }).click()
+    await waitFor(() => rendered.getByText('error: some error'))
+
+    expect(count).toBe(3)
+
+    consoleMock.mockRestore()
+  })
+
+  it('should restart when observers unmount and remount while waiting for a retry when query was cancelled in between (#3031)', async () => {
+    const key = queryKey()
+    const consoleMock = mockConsoleError()
+    let count = 0
+
+    function Page() {
+      const result = useQuery(
+        key,
+        async () => {
+          count++
+          await sleep(10)
+          return Promise.reject('some error')
+        },
+        {
+          retry: 2,
+          retryDelay: 100,
+        }
+      )
+
+      return (
+        <div>
+          <div>error: {result.error ?? 'null'}</div>
+          <div>failureCount: {result.failureCount}</div>
+        </div>
+      )
+    }
+
+    function App() {
+      const [show, toggle] = React.useReducer(x => !x, true)
+
+      return (
+        <div>
+          <button onClick={toggle}>{show ? 'hide' : 'show'}</button>
+          <button onClick={() => queryClient.cancelQueries({ queryKey: key })}>
+            cancel
+          </button>
+          {show && <Page />}
+        </div>
+      )
+    }
+
+    const rendered = renderWithClient(queryClient, <App />)
+
+    await waitFor(() => rendered.getByText('failureCount: 1'))
+    rendered.getByRole('button', { name: /hide/i }).click()
+    rendered.getByRole('button', { name: /cancel/i }).click()
+    rendered.getByRole('button', { name: /show/i }).click()
+    await waitFor(() => rendered.getByText('error: some error'))
+
+    // initial fetch (1), which will be cancelled, followed by new mount(2) + 2 retries = 4
+    expect(count).toBe(4)
+
+    consoleMock.mockRestore()
+  })
+
   it('should always fetch if refetchOnMount is set to always', async () => {
     const key = queryKey()
     const states: UseQueryResult<string>[] = []