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
6 changes: 6 additions & 0 deletions .changeset/eager-dingos-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/react-query': patch
'@tanstack/preact-query': patch
---

fix(react-query, preact-query): retry queries caught by an error boundary when they mount after the reset wave
44 changes: 35 additions & 9 deletions packages/preact-query/src/QueryErrorResetBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@ import { useContext, useState } from 'preact/hooks'
export type QueryErrorResetFunction = () => void

/**
* Returns whether the boundary has been reset and not yet cleared.
* Returns whether the boundary has been reset and not yet cleared. When a `queryHash` is
* given, only checks whether the boundary has been reset since that query last cleared its
* own reset state.
*/
export type QueryErrorIsResetFunction = () => boolean
export type QueryErrorIsResetFunction = (queryHash?: string) => boolean

/**
* Clears the reset state, so queries know not to try again until the boundary is reset again.
* When a `queryHash` is given, only that query's view of the reset state is cleared, so
* sibling queries can still observe the reset.
*/
export type QueryErrorClearResetFunction = () => void
export type QueryErrorClearResetFunction = (queryHash?: string) => void

/**
* The value a `QueryErrorResetBoundary` shares through context, used to reset query errors within
Expand All @@ -44,16 +48,38 @@ export interface QueryErrorResetBoundaryValue {
* @returns The `clearReset`, `isReset`, and `reset` functions of the boundary.
*/
function createValue(): QueryErrorResetBoundaryValue {
let isReset = false
// Each `reset()` starts a new reset generation and every query tracks the
// generation it last cleared, so one mounted query cannot consume the reset
// signal before other queries have seen it.
let resetId = 0
let resetPending = false
const clearedQueries = new Map<string, number>()
return {
clearReset: () => {
isReset = false
clearReset: (queryHash) => {
if (queryHash === undefined) {
resetId = 0
resetPending = false
clearedQueries.clear()
} else {
clearedQueries.set(queryHash, resetId)
resetPending = false
}
},
reset: () => {
isReset = true
resetId += 1
resetPending = true
},
isReset: () => {
return isReset
isReset: (queryHash) => {
if (queryHash === undefined) {
return resetPending
}
const clearedResetId = clearedQueries.get(queryHash)
// Queries that never cleared their own reset state follow the shared
// reset wave (cleared by the first mounted query), while queries that
// did clear it keep their own pending state until they observe it.
return clearedResetId === undefined
? resetPending
: resetId > clearedResetId
},
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { fireEvent } from '@testing-library/preact'
import { act, fireEvent } from '@testing-library/preact'
import { Suspense } from 'preact/compat'
import { useEffect, useState } from 'preact/hooks'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
Expand Down Expand Up @@ -732,6 +732,95 @@ describe('QueryErrorResetBoundary', () => {
})
})

it('should retry an errored query that mounts after a sibling query already consumed the reset', async () => {
const consoleErrorMock = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
const key = queryKey()
const siblingKey = queryKey()

let succeed = false
let showPage!: (show: boolean) => void

function ErroredPage() {
const { data } = useQuery({
queryKey: key,
queryFn: () =>
sleep(10).then(() => {
if (!succeed) throw new Error('Error')
return 'data'
}),
retry: false,
throwOnError: true,
})

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

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

return null
}

function App() {
const [show, setShow] = useState(true)
showPage = setShow

return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
<div>error boundary</div>
<button
onClick={() => {
resetErrorBoundary()
}}
>
retry
</button>
</div>
)}
>
<Sibling />
{show ? <ErroredPage /> : null}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
)
}

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

await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('error boundary')).toBeInTheDocument()

// Reset the boundary while the errored query is not rendered, so the
// sibling's mount is the only query that observes the reset.
act(() => {
showPage(false)
})
fireEvent.click(rendered.getByText('retry'))
await vi.advanceTimersByTimeAsync(11)

// Mounting the errored query again must still retry it: the sibling
// must not have consumed the reset on its behalf.
succeed = true
act(() => {
showPage(true)
})
await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('data')).toBeInTheDocument()

consoleErrorMock.mockRestore()
})

describe('useQueries', () => {
it('should retry fetch if the reset error boundary has been reset', async () => {
const consoleErrorMock = vi
Expand Down
11 changes: 7 additions & 4 deletions packages/preact-query/src/errorBoundaryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const ensurePreventErrorBoundaryRetry = <

if (options.suspense || throwOnError) {
// Prevent retrying failed query if the error boundary has not been reset yet
if (!errorResetBoundary.isReset()) {
if (!errorResetBoundary.isReset(options.queryHash)) {
options.retryOnMount = false
}
}
Expand All @@ -52,13 +52,16 @@ export const ensurePreventErrorBoundaryRetry = <
* Clears the reset state of the error boundary after the component mounts, so later errors are
* thrown to the boundary again.
* @param errorResetBoundary - The value of the nearest `QueryErrorResetBoundary`.
* @param queryHash - The hash(es) of the queries to clear the reset state for.
*/
export const useClearResetErrorBoundary = (
errorResetBoundary: QueryErrorResetBoundaryValue,
queryHash: string | Array<string>,
) => {
useEffect(() => {
errorResetBoundary.clearReset()
}, [errorResetBoundary])
const queryHashes = Array.isArray(queryHash) ? queryHash : [queryHash]
queryHashes.forEach((hash) => errorResetBoundary.clearReset(hash))
}, [errorResetBoundary, queryHash])
}

/**
Expand Down Expand Up @@ -90,7 +93,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/preact-query/src/suspense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,5 @@ export const fetchOptimistic = <
errorResetBoundary: QueryErrorResetBoundaryValue,
) =>
observer.fetchOptimistic(defaultedOptions).catch(() => {
errorResetBoundary.clearReset()
errorResetBoundary.clearReset(defaultedOptions.queryHash)
})
2 changes: 1 addition & 1 deletion packages/preact-query/src/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function useBaseQuery<
ensureSuspenseTimers(defaultedOptions)
ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary, query)

useClearResetErrorBoundary(errorResetBoundary)
useClearResetErrorBoundary(errorResetBoundary, defaultedOptions.queryHash)

const [observer] = useState(
() =>
Expand Down
5 changes: 4 additions & 1 deletion packages/preact-query/src/useQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,10 @@ export function useQueries<
ensurePreventErrorBoundaryRetry(queryOptions, errorResetBoundary, query)
})

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

const [observer] = useState(
() =>
Expand Down
54 changes: 35 additions & 19 deletions packages/react-query/src/QueryErrorResetBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ import * as React from 'react'
export type QueryErrorResetFunction = () => void

/**
* Returns whether the boundary has been reset and not yet cleared.
* Returns whether the boundary has been reset and not yet cleared. When a `queryHash` is
* given, only checks whether the boundary has been reset since that query last cleared its
* own reset state.
*/
export type QueryErrorIsResetFunction = () => boolean
export type QueryErrorIsResetFunction = (queryHash?: string) => boolean

/**
* Clears the reset state, so queries know not to try again until the boundary is reset again.
* When a `queryHash` is given, only that query's view of the reset state is cleared, so
* sibling queries can still observe the reset.
*/
export type QueryErrorClearResetFunction = () => void
export type QueryErrorClearResetFunction = (queryHash?: string) => void

/**
* The value a `QueryErrorResetBoundary` shares through context, used to reset query errors within
Expand All @@ -43,26 +47,38 @@ export interface QueryErrorResetBoundaryValue {
* @returns The `clearReset`, `isReset`, and `reset` functions of the boundary.
*/
function createValue(): QueryErrorResetBoundaryValue {
let isReset = false
// Each `reset()` starts a new reset generation and every query tracks the
// generation it last cleared, so one mounted query cannot consume the reset
// signal before other queries have seen it.
let resetId = 0
let resetPending = false
const clearedQueries = new Map<string, number>()
return {
/**
* Clears the reset state, so queries know not to try again until the boundary is reset again.
*/
clearReset: () => {
isReset = false
clearReset: (queryHash) => {
if (queryHash === undefined) {
resetId = 0
resetPending = false
clearedQueries.clear()
} else {
clearedQueries.set(queryHash, resetId)
resetPending = false
}
},
/**
* Resets any query errors within the boundary, so queries know they can try again.
*/
reset: () => {
isReset = true
resetId += 1
resetPending = true
},
/**
* Returns whether the boundary has been reset and not yet cleared.
* @returns `true` if the boundary has been reset and not yet cleared.
*/
isReset: () => {
return isReset
isReset: (queryHash) => {
if (queryHash === undefined) {
return resetPending
}
const clearedResetId = clearedQueries.get(queryHash)
// Queries that never cleared their own reset state follow the shared
// reset wave (cleared by the first mounted query), while queries that
// did clear it keep their own pending state until they observe it.
return clearedResetId === undefined
? resetPending
: resetId > clearedResetId
},
}
}
Expand Down
Loading