diff --git a/.changeset/calm-masks-reset.md b/.changeset/calm-masks-reset.md new file mode 100644 index 00000000000..dc097a14bc7 --- /dev/null +++ b/.changeset/calm-masks-reset.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-query': patch +--- + +Scope query error reset tracking per query hash so sibling queries do not consume an error boundary reset before an errored query remounts. diff --git a/packages/react-query/src/QueryErrorResetBoundary.tsx b/packages/react-query/src/QueryErrorResetBoundary.tsx index 910215bcb6d..600dd6f68ae 100644 --- a/packages/react-query/src/QueryErrorResetBoundary.tsx +++ b/packages/react-query/src/QueryErrorResetBoundary.tsx @@ -3,8 +3,8 @@ import * as React from 'react' // CONTEXT export type QueryErrorResetFunction = () => void -export type QueryErrorIsResetFunction = () => boolean -export type QueryErrorClearResetFunction = () => void +export type QueryErrorIsResetFunction = (queryHash?: string) => boolean +export type QueryErrorClearResetFunction = (queryHash?: string) => void export interface QueryErrorResetBoundaryValue { clearReset: QueryErrorClearResetFunction @@ -14,15 +14,31 @@ export interface QueryErrorResetBoundaryValue { function createValue(): QueryErrorResetBoundaryValue { let isReset = false + let resetId = 0 + const queryResetIds = new Map() + return { - clearReset: () => { + clearReset: (queryHash?: string) => { isReset = false + + if (queryHash) { + queryResetIds.set(queryHash, resetId) + } else { + queryResetIds.clear() + resetId = 0 + } }, reset: () => { isReset = true + resetId += 1 }, - isReset: () => { - return isReset + isReset: (queryHash?: string) => { + if (!queryHash) { + return isReset + } + + const queryResetId = queryResetIds.get(queryHash) ?? resetId + return queryResetId < resetId }, } } diff --git a/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx b/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx index f9973bba2cc..e85d66dbc93 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,93 @@ describe('QueryErrorResetBoundary', () => { consoleMock.mockRestore() }) + it('should not let sibling queries consume another query reset', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const errorKey = queryKey() + const siblingKey = queryKey() + let shouldSucceed = false + const queryFn = vi.fn(() => + sleep(10).then(() => { + if (!shouldSucceed) { + throw new Error('Error') + } + + return 'data' + }), + ) + + function ResetOnUnmountFallback() { + const { reset } = useQueryErrorResetBoundary() + + React.useEffect(() => reset, [reset]) + + return
error boundary
+ } + + function ErrorPage() { + const { data } = useSuspenseQuery({ + queryKey: errorKey, + queryFn, + retry: false, + }) + + return
{data}
+ } + + function SiblingPage() { + const { data } = useQuery({ + queryKey: siblingKey, + queryFn: () => sleep(10).then(() => 'sibling data'), + }) + + return
{data}
+ } + + function App() { + const [showErrorPage, setShowErrorPage] = React.useState(true) + + return ( + + + {showErrorPage ? ( + }> + + + + + ) : ( + + )} + + ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + + fireEvent.click(rendered.getByText('toggle')) + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('sibling data')).toBeInTheDocument() + + shouldSucceed = true + fireEvent.click(rendered.getByText('toggle')) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(11)) + 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..d2342ebfe7f 100644 --- a/packages/react-query/src/errorBoundaryUtils.ts +++ b/packages/react-query/src/errorBoundaryUtils.ts @@ -38,7 +38,7 @@ export const ensurePreventErrorBoundaryRetry = < throwOnError ) { // Prevent retrying failed query if the error boundary has not been reset yet - if (!errorResetBoundary.isReset()) { + if (!errorResetBoundary.isReset(query?.queryHash)) { options.retryOnMount = false } } @@ -46,10 +46,13 @@ export const ensurePreventErrorBoundaryRetry = < export const useClearResetErrorBoundary = ( errorResetBoundary: QueryErrorResetBoundaryValue, + queryHashes: Array, ) => { React.useEffect(() => { - errorResetBoundary.clearReset() - }, [errorResetBoundary]) + queryHashes.forEach((queryHash) => { + errorResetBoundary.clearReset(queryHash) + }) + }, [errorResetBoundary, queryHashes]) } export const getHasError = < @@ -73,7 +76,7 @@ export const getHasError = < }) => { return ( result.isError && - !errorResetBoundary.isReset() && + !errorResetBoundary.isReset(query?.queryHash) && !result.isFetching && query && ((suspense && result.data === undefined) || diff --git a/packages/react-query/src/suspense.ts b/packages/react-query/src/suspense.ts index d87569f103f..d3cbd7a954e 100644 --- a/packages/react-query/src/suspense.ts +++ b/packages/react-query/src/suspense.ts @@ -76,5 +76,5 @@ export const fetchOptimistic = < errorResetBoundary: QueryErrorResetBoundaryValue, ) => observer.fetchOptimistic(defaultedOptions).catch(() => { - errorResetBoundary.clearReset() + errorResetBoundary.clearReset(defaultedOptions.queryHash) }) diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index a88f7d40fbf..a1623938dc5 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -81,7 +81,7 @@ export function useBaseQuery< ensureSuspenseTimers(defaultedOptions) ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary, query) - useClearResetErrorBoundary(errorResetBoundary) + useClearResetErrorBoundary(errorResetBoundary, [defaultedOptions.queryHash]) // this needs to be invoked before creating the Observer because that can create a cache entry const isNewCacheEntry = !client diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index de179837a5f..551b6d1395b 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -247,7 +247,10 @@ export function useQueries< ensurePreventErrorBoundaryRetry(queryOptions, errorResetBoundary, query) }) - useClearResetErrorBoundary(errorResetBoundary) + useClearResetErrorBoundary( + errorResetBoundary, + defaultedQueries.map((queryOptions) => queryOptions.queryHash), + ) const [observer] = React.useState( () =>