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/calm-masks-reset.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 21 additions & 5 deletions packages/react-query/src/QueryErrorResetBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,15 +14,31 @@ export interface QueryErrorResetBoundaryValue {

function createValue(): QueryErrorResetBoundaryValue {
let isReset = false
let resetId = 0
const queryResetIds = new Map<string, number>()

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
},
}
}
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,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 <div>error boundary</div>
}

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

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

function SiblingPage() {
const { data } = useQuery({
queryKey: siblingKey,
queryFn: () => sleep(10).then(() => 'sibling data'),
})

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

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

return (
<QueryErrorResetBoundary>
<button onClick={() => setShowErrorPage((value) => !value)}>
toggle
</button>
{showErrorPage ? (
<ErrorBoundary fallback={<ResetOnUnmountFallback />}>
<React.Suspense fallback="loading">
<ErrorPage />
</React.Suspense>
</ErrorBoundary>
) : (
<SiblingPage />
)}
</QueryErrorResetBoundary>
)
}

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

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')
Expand Down
11 changes: 7 additions & 4 deletions packages/react-query/src/errorBoundaryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,21 @@ 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
}
}
}

export const useClearResetErrorBoundary = (
errorResetBoundary: QueryErrorResetBoundaryValue,
queryHashes: Array<string | undefined>,
) => {
React.useEffect(() => {
errorResetBoundary.clearReset()
}, [errorResetBoundary])
queryHashes.forEach((queryHash) => {
errorResetBoundary.clearReset(queryHash)
})
}, [errorResetBoundary, queryHashes])
}

export const getHasError = <
Expand All @@ -73,7 +76,7 @@ export const getHasError = <
}) => {
return (
result.isError &&
!errorResetBoundary.isReset() &&
!errorResetBoundary.isReset(query?.queryHash) &&
!result.isFetching &&
query &&
((suspense && result.data === undefined) ||
Expand Down
2 changes: 1 addition & 1 deletion packages/react-query/src/suspense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,5 @@ export const fetchOptimistic = <
errorResetBoundary: QueryErrorResetBoundaryValue,
) =>
observer.fetchOptimistic(defaultedOptions).catch(() => {
errorResetBoundary.clearReset()
errorResetBoundary.clearReset(defaultedOptions.queryHash)
})
2 changes: 1 addition & 1 deletion packages/react-query/src/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/react-query/src/useQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,10 @@ export function useQueries<
ensurePreventErrorBoundaryRetry(queryOptions, errorResetBoundary, query)
})

useClearResetErrorBoundary(errorResetBoundary)
useClearResetErrorBoundary(
errorResetBoundary,
defaultedQueries.map((queryOptions) => queryOptions.queryHash),
)

const [observer] = React.useState(
() =>
Expand Down