Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: suspense error handling #5979

Merged
merged 1 commit into from
Sep 8, 2023
Merged
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
27 changes: 26 additions & 1 deletion docs/react/guides/suspense.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,32 @@ const { data } = useSuspenseQuery({ queryKey, queryFn })

This works nicely in TypeScript, because `data` is guaranteed to be defined (as errors and loading states are handled by Suspense- and ErrorBoundaries).

On the flip side, you therefore can't conditionally enable / disable the Query. `placeholderData` also doesn't exist for this Query. To prevent the UI from being replaced by a fallback during an update, wrap your updates that change the QueryKey into [startTransition](https://react.dev/reference/react/Suspense#preventing-unwanted-fallbacks).
On the flip side, you therefore can't conditionally enable / disable the Query. This generally shouldn't be necessary for dependent Queries because with suspense, all your Queries inside one component are fetched in serial.

`placeholderData` also doesn't exist for this Query. To prevent the UI from being replaced by a fallback during an update, wrap your updates that change the QueryKey into [startTransition](https://react.dev/reference/react/Suspense#preventing-unwanted-fallbacks).

### throwOnError default

Not all errors are thrown to the nearest Error Boundary per default - we're only throwing errors if there is no other data to show. That means if a Query ever successfully got data in the cache, the component will render, even if data is `stale`. Thus, the default for `throwOnError` is:

```
throwOnError: (error, query) => typeof query.state.data === 'undefined'
```

Since you can't change `throwOnError` (because it would allow for `data` to become potentially `undefined`), you have to throw errors manually if you want all errors to be handled by Error Boundaries:

```tsx
import { useSuspenseQuery } from '@tanstack/react-query'

const { data, error } = useSuspenseQuery({ queryKey, queryFn })

if (error) {
throw error
}
TkDodo marked this conversation as resolved.
Show resolved Hide resolved

// continue rendering data

```

## Resetting Error Boundaries

Expand Down
67 changes: 67 additions & 0 deletions packages/react-query/src/__tests__/suspense.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,11 @@ describe('useSuspenseQuery', () => {
},
retry: false,
})

if (result.error) {
throw result.error
}

return (
<div>
<span>rendered</span> <span>{result.data}</span>
Expand Down Expand Up @@ -712,6 +717,68 @@ describe('useSuspenseQuery', () => {
expect(renders).toBe(2)
expect(rendered.queryByText('rendered')).not.toBeNull()
})

it('should not throw background errors to the error boundary', async () => {
const consoleMock = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
let succeed = true
const key = queryKey()

function Page() {
const result = useSuspenseQuery({
queryKey: key,
queryFn: async () => {
await sleep(10)
if (!succeed) {
throw new Error('Suspense Error Bingo')
} else {
return 'data'
}
},
retry: false,
})

return (
<div>
<span>
rendered {result.data} {result.status}
</span>
<button onClick={() => result.refetch()}>refetch</button>
</div>
)
}

function App() {
const { reset } = useQueryErrorResetBoundary()
return (
<ErrorBoundary
onReset={reset}
fallbackRender={() => <div>error boundary</div>}
>
<React.Suspense fallback="Loading...">
<Page />
</React.Suspense>
</ErrorBoundary>
)
}

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

// render suspense fallback (Loading...)
await waitFor(() => rendered.getByText('Loading...'))
// resolve promise -> render Page (rendered)
await waitFor(() => rendered.getByText('rendered data success'))

// change promise result to error
succeed = false
// refetch
fireEvent.click(rendered.getByRole('button', { name: 'refetch' }))
// we are now in error state but still have data to show
await waitFor(() => rendered.getByText('rendered data error'))

consoleMock.mockRestore()
})
})

describe('useSuspenseQueries', () => {
Expand Down
12 changes: 12 additions & 0 deletions packages/react-query/src/suspense.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import type { DefaultError } from '@tanstack/query-core/src'
import type {
DefaultedQueryObserverOptions,
Query,
QueryKey,
QueryObserver,
QueryObserverResult,
} from '@tanstack/query-core'
import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary'

export const defaultThrowOnError = <
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
_error: TError,
query: Query<TQueryFnData, TError, TData, TQueryKey>,
) => typeof query.state.data === 'undefined'

export const ensureStaleTime = (
defaultedOptions: DefaultedQueryObserverOptions<any, any, any, any, any>,
) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/react-query/src/useSuspenseQueries.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'
import { useQueries } from './useQueries'
import { defaultThrowOnError } from './suspense'
import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types'
import type {
DefaultError,
Expand Down Expand Up @@ -154,7 +155,7 @@ export function useSuspenseQueries<
queries: options.queries.map((query) => ({
...query,
suspense: true,
throwOnError: true,
throwOnError: defaultThrowOnError,
enabled: true,
})),
} as any,
Expand Down
3 changes: 2 additions & 1 deletion packages/react-query/src/useSuspenseQuery.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'
import { QueryObserver } from '@tanstack/query-core'
import { useBaseQuery } from './useBaseQuery'
import { defaultThrowOnError } from './suspense'
import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types'
import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core'

Expand All @@ -18,7 +19,7 @@ export function useSuspenseQuery<
...options,
enabled: true,
suspense: true,
throwOnError: true,
throwOnError: defaultThrowOnError,
},
QueryObserver,
queryClient,
Expand Down