Skip to content

Commit

Permalink
feat: suspense error handling
Browse files Browse the repository at this point in the history
only hard errors are thrown to ErrorBoundaries
  • Loading branch information
TkDodo committed Sep 8, 2023
1 parent 6ac08b4 commit eff51e1
Show file tree
Hide file tree
Showing 5 changed files with 109 additions and 3 deletions.
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
}

// 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

0 comments on commit eff51e1

Please sign in to comment.