Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/error-boundary-remount-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-query': patch
---

Preserve error boundary reset retries for remounted queries when another query clears the reset state first.
8 changes: 8 additions & 0 deletions packages/react-query/src/QueryErrorResetBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,29 @@ import * as React from 'react'
export type QueryErrorResetFunction = () => void
export type QueryErrorIsResetFunction = () => boolean
export type QueryErrorClearResetFunction = () => void
export type QueryErrorResetCountFunction = () => number

export interface QueryErrorResetBoundaryValue {
clearReset: QueryErrorClearResetFunction
getResetCount?: QueryErrorResetCountFunction
isReset: QueryErrorIsResetFunction
reset: QueryErrorResetFunction
}

function createValue(): QueryErrorResetBoundaryValue {
let isReset = false
let resetCount = 0

return {
clearReset: () => {
isReset = false
},
getResetCount: () => {
return resetCount
},
reset: () => {
isReset = true
resetCount++
},
isReset: () => {
return isReset
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
QueryErrorResetBoundary,
useQueries,
useQuery,
useQueryErrorResetBoundary,
useSuspenseQueries,
useSuspenseQuery,
} from '..'
Expand Down Expand Up @@ -91,6 +92,104 @@ describe('QueryErrorResetBoundary', () => {
consoleMock.mockRestore()
})

it('should retry a remounted error query when another query mounted after reset', async () => {
const consoleMock = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
const errorKey = queryKey()
const otherKey = queryKey()

let succeed = false

const queryFn = vi.fn(() =>
sleep(10).then(() => {
if (!succeed) {
throw new Error('Error')
}
return 'data'
}),
)

function ErrorFallback() {
const { reset } = useQueryErrorResetBoundary()

React.useEffect(() => {
return () => {
reset()
}
}, [reset])

return <div>error boundary</div>
}

function ErrorPage() {
const { data } = useSuspenseQuery({
queryKey: errorKey,
queryFn,
retry: false,
retryOnMount: true,
})

return <div>{data}</div>
}

function OtherPage() {
const { data } = useSuspenseQuery({
queryKey: otherKey,
queryFn: () => sleep(10).then(() => 'other'),
})

return <div>{data}</div>
}

function App() {
const [showErrorPage, setShowErrorPage] = React.useState(true)

return (
<div>
<button onClick={() => setShowErrorPage((show) => !show)}>
toggle
</button>
{showErrorPage ? (
<ErrorBoundary fallback={<ErrorFallback />}>
<React.Suspense fallback="loading">
<ErrorPage />
</React.Suspense>
</ErrorBoundary>
) : (
<React.Suspense fallback="loading">
<OtherPage />
</React.Suspense>
)}
</div>
)
}

const rendered = renderWithClient(
queryClient,
<QueryErrorResetBoundary>
<App />
</QueryErrorResetBoundary>,
)

await act(() => vi.advanceTimersByTimeAsync(10))
expect(rendered.getByText('error boundary')).toBeInTheDocument()
expect(queryFn).toHaveBeenCalledTimes(1)

fireEvent.click(rendered.getByText('toggle'))
await act(() => vi.advanceTimersByTimeAsync(10))
expect(rendered.getByText('other')).toBeInTheDocument()

succeed = true

fireEvent.click(rendered.getByText('toggle'))
await act(() => vi.advanceTimersByTimeAsync(10))
expect(rendered.getByText('data')).toBeInTheDocument()
expect(queryFn).toHaveBeenCalledTimes(2)

consoleMock.mockRestore()
})

it('should not throw error if query is disabled', async () => {
const consoleMock = vi
.spyOn(console, 'error')
Expand Down
65 changes: 64 additions & 1 deletion packages/react-query/src/errorBoundaryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,69 @@ import type {
} from '@tanstack/query-core'
import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary'

const queryResetCounts = new WeakMap<
QueryErrorResetBoundaryValue,
WeakMap<object, number>
>()

function getResetCount(errorResetBoundary: QueryErrorResetBoundaryValue) {
return errorResetBoundary.getResetCount?.()
}

function getQueryResetCounts(errorResetBoundary: QueryErrorResetBoundaryValue) {
let resetCounts = queryResetCounts.get(errorResetBoundary)

if (!resetCounts) {
resetCounts = new WeakMap()
queryResetCounts.set(errorResetBoundary, resetCounts)
}

return resetCounts
}

function isResetForQuery<
TQueryFnData,
TError,
TQueryData,
TQueryKey extends QueryKey,
>(
errorResetBoundary: QueryErrorResetBoundaryValue,
query: Query<TQueryFnData, TError, TQueryData, TQueryKey> | undefined,
) {
const resetCount = getResetCount(errorResetBoundary)

if (errorResetBoundary.isReset()) {
if (query && resetCount) {
getQueryResetCounts(errorResetBoundary).set(query, resetCount)
}

return resetCount === undefined || resetCount > 0
}

if (!query) {
return false
}

const resetCounts = getQueryResetCounts(errorResetBoundary)
const queryResetCount = resetCounts.get(query)

if (queryResetCount === undefined) {
resetCounts.set(query, resetCount ?? 0)
return false
}

if (!resetCount) {
return false
}

if (resetCount > queryResetCount) {
resetCounts.set(query, resetCount)
return true
}

return false
}

export const ensurePreventErrorBoundaryRetry = <
TQueryFnData,
TError,
Expand Down Expand Up @@ -38,7 +101,7 @@ export const ensurePreventErrorBoundaryRetry = <
throwOnError
) {
// Prevent retrying failed query if the error boundary has not been reset yet
if (!errorResetBoundary.isReset()) {
if (!isResetForQuery(errorResetBoundary, query)) {
options.retryOnMount = false
}
}
Expand Down