diff --git a/.changeset/error-boundary-remount-retry.md b/.changeset/error-boundary-remount-retry.md new file mode 100644 index 00000000000..f10155ae037 --- /dev/null +++ b/.changeset/error-boundary-remount-retry.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-query': patch +--- + +Preserve error boundary reset retries for remounted queries when another query clears the reset state first. diff --git a/packages/react-query/src/QueryErrorResetBoundary.tsx b/packages/react-query/src/QueryErrorResetBoundary.tsx index 910215bcb6d..70e2f6ff8c8 100644 --- a/packages/react-query/src/QueryErrorResetBoundary.tsx +++ b/packages/react-query/src/QueryErrorResetBoundary.tsx @@ -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 diff --git a/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx b/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx index 21d594129fb..89d5e2ce332 100644 --- a/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx +++ b/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx @@ -9,6 +9,7 @@ import { QueryErrorResetBoundary, useQueries, useQuery, + useQueryErrorResetBoundary, useSuspenseQueries, useSuspenseQuery, } from '..' @@ -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
error boundary
+ } + + function ErrorPage() { + const { data } = useSuspenseQuery({ + queryKey: errorKey, + queryFn, + retry: false, + retryOnMount: true, + }) + + return
{data}
+ } + + function OtherPage() { + const { data } = useSuspenseQuery({ + queryKey: otherKey, + queryFn: () => sleep(10).then(() => 'other'), + }) + + return
{data}
+ } + + function App() { + const [showErrorPage, setShowErrorPage] = React.useState(true) + + return ( +
+ + {showErrorPage ? ( + }> + + + + + ) : ( + + + + )} +
+ ) + } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + 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') diff --git a/packages/react-query/src/errorBoundaryUtils.ts b/packages/react-query/src/errorBoundaryUtils.ts index 734cc74d3de..812b7cbfb53 100644 --- a/packages/react-query/src/errorBoundaryUtils.ts +++ b/packages/react-query/src/errorBoundaryUtils.ts @@ -10,6 +10,69 @@ import type { } from '@tanstack/query-core' import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary' +const queryResetCounts = new WeakMap< + QueryErrorResetBoundaryValue, + WeakMap +>() + +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 | 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, @@ -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 } }