From dc2df1066fb35593ab9f7b852ee250424f212737 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sun, 28 Nov 2021 08:17:22 +0100 Subject: [PATCH] fix(useQuery): continue retries if query unmounts and remounts (#3032) * fix(useQuery): continue retries if observer unmount and remount if we are waiting for a retry to happen, unsubscribing he last observer will cancel retries and just return the error; however, if a new observer subscribes in the meantime, we should continue with the ongoing retries. this is especially important with "strict effects" in react18, where effects are run twice and thus observers are always unsubscribed and re-subscribed immediately. * fix(useQuery): continue retries if observer unmount and remount add another test to include query cancellation --- src/core/query.ts | 2 + src/core/retryer.ts | 4 ++ src/react/tests/useQuery.test.tsx | 105 ++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+) diff --git a/src/core/query.ts b/src/core/query.ts index c275876e9a..1b5a3854ad 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 d1c6e162d0..07621123d4 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 { cancel: (options?: CancelOptions) => void cancelRetry: () => void + continueRetry: () => void continue: () => void failureCount: number isPaused: boolean @@ -82,6 +83,9 @@ export class Retryer { this.cancelRetry = () => { cancelRetry = true } + this.continueRetry = () => { + cancelRetry = false + } this.continue = () => continueFn?.() this.failureCount = 0 this.isPaused = false diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 547d5ddf1b..54b1f40a15 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 ( +
+
error: {result.error ?? 'null'}
+
failureCount: {result.failureCount}
+
+ ) + } + + function App() { + const [show, toggle] = React.useReducer(x => !x, true) + + return ( +
+ + {show && } +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + 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 ( +
+
error: {result.error ?? 'null'}
+
failureCount: {result.failureCount}
+
+ ) + } + + function App() { + const [show, toggle] = React.useReducer(x => !x, true) + + return ( +
+ + + {show && } +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + 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[] = []