Skip to content

Commit dc2df10

Browse files
authored
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
1 parent c51498a commit dc2df10

File tree

3 files changed

+111
-0
lines changed

3 files changed

+111
-0
lines changed

src/core/query.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,8 @@ export class Query<
380380
// Silently cancel current fetch if the user wants to cancel refetches
381381
this.cancel({ silent: true })
382382
} else if (this.promise) {
383+
// make sure that retries that were potentially cancelled due to unmounts can continue
384+
this.retryer?.continueRetry()
383385
// Return current promise if we are already fetching
384386
return this.promise
385387
}

src/core/retryer.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export function isCancelledError(value: any): value is CancelledError {
6161
export class Retryer<TData = unknown, TError = unknown> {
6262
cancel: (options?: CancelOptions) => void
6363
cancelRetry: () => void
64+
continueRetry: () => void
6465
continue: () => void
6566
failureCount: number
6667
isPaused: boolean
@@ -82,6 +83,9 @@ export class Retryer<TData = unknown, TError = unknown> {
8283
this.cancelRetry = () => {
8384
cancelRetry = true
8485
}
86+
this.continueRetry = () => {
87+
cancelRetry = false
88+
}
8589
this.continue = () => continueFn?.()
8690
this.failureCount = 0
8791
this.isPaused = false

src/react/tests/useQuery.test.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2671,6 +2671,111 @@ describe('useQuery', () => {
26712671
consoleMock.mockRestore()
26722672
})
26732673

2674+
it('should continue retries when observers unmount and remount while waiting for a retry (#3031)', async () => {
2675+
const key = queryKey()
2676+
const consoleMock = mockConsoleError()
2677+
let count = 0
2678+
2679+
function Page() {
2680+
const result = useQuery(
2681+
key,
2682+
async () => {
2683+
count++
2684+
await sleep(10)
2685+
return Promise.reject('some error')
2686+
},
2687+
{
2688+
retry: 2,
2689+
retryDelay: 100,
2690+
}
2691+
)
2692+
2693+
return (
2694+
<div>
2695+
<div>error: {result.error ?? 'null'}</div>
2696+
<div>failureCount: {result.failureCount}</div>
2697+
</div>
2698+
)
2699+
}
2700+
2701+
function App() {
2702+
const [show, toggle] = React.useReducer(x => !x, true)
2703+
2704+
return (
2705+
<div>
2706+
<button onClick={toggle}>{show ? 'hide' : 'show'}</button>
2707+
{show && <Page />}
2708+
</div>
2709+
)
2710+
}
2711+
2712+
const rendered = renderWithClient(queryClient, <App />)
2713+
2714+
await waitFor(() => rendered.getByText('failureCount: 1'))
2715+
rendered.getByRole('button', { name: /hide/i }).click()
2716+
rendered.getByRole('button', { name: /show/i }).click()
2717+
await waitFor(() => rendered.getByText('error: some error'))
2718+
2719+
expect(count).toBe(3)
2720+
2721+
consoleMock.mockRestore()
2722+
})
2723+
2724+
it('should restart when observers unmount and remount while waiting for a retry when query was cancelled in between (#3031)', async () => {
2725+
const key = queryKey()
2726+
const consoleMock = mockConsoleError()
2727+
let count = 0
2728+
2729+
function Page() {
2730+
const result = useQuery(
2731+
key,
2732+
async () => {
2733+
count++
2734+
await sleep(10)
2735+
return Promise.reject('some error')
2736+
},
2737+
{
2738+
retry: 2,
2739+
retryDelay: 100,
2740+
}
2741+
)
2742+
2743+
return (
2744+
<div>
2745+
<div>error: {result.error ?? 'null'}</div>
2746+
<div>failureCount: {result.failureCount}</div>
2747+
</div>
2748+
)
2749+
}
2750+
2751+
function App() {
2752+
const [show, toggle] = React.useReducer(x => !x, true)
2753+
2754+
return (
2755+
<div>
2756+
<button onClick={toggle}>{show ? 'hide' : 'show'}</button>
2757+
<button onClick={() => queryClient.cancelQueries({ queryKey: key })}>
2758+
cancel
2759+
</button>
2760+
{show && <Page />}
2761+
</div>
2762+
)
2763+
}
2764+
2765+
const rendered = renderWithClient(queryClient, <App />)
2766+
2767+
await waitFor(() => rendered.getByText('failureCount: 1'))
2768+
rendered.getByRole('button', { name: /hide/i }).click()
2769+
rendered.getByRole('button', { name: /cancel/i }).click()
2770+
rendered.getByRole('button', { name: /show/i }).click()
2771+
await waitFor(() => rendered.getByText('error: some error'))
2772+
2773+
// initial fetch (1), which will be cancelled, followed by new mount(2) + 2 retries = 4
2774+
expect(count).toBe(4)
2775+
2776+
consoleMock.mockRestore()
2777+
})
2778+
26742779
it('should always fetch if refetchOnMount is set to always', async () => {
26752780
const key = queryKey()
26762781
const states: UseQueryResult<string>[] = []

0 commit comments

Comments
 (0)