diff --git a/docs/react/guides/suspense.md b/docs/react/guides/suspense.md index dc97a308cc..045cccfab0 100644 --- a/docs/react/guides/suspense.md +++ b/docs/react/guides/suspense.md @@ -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 diff --git a/packages/react-query/src/__tests__/suspense.test.tsx b/packages/react-query/src/__tests__/suspense.test.tsx index a0fd59de60..cc9d5b6055 100644 --- a/packages/react-query/src/__tests__/suspense.test.tsx +++ b/packages/react-query/src/__tests__/suspense.test.tsx @@ -624,6 +624,11 @@ describe('useSuspenseQuery', () => { }, retry: false, }) + + if (result.error) { + throw result.error + } + return (
rendered {result.data} @@ -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 ( +
+ + rendered {result.data} {result.status} + + +
+ ) + } + + function App() { + const { reset } = useQueryErrorResetBoundary() + return ( +
error boundary
} + > + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + // 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', () => { diff --git a/packages/react-query/src/suspense.ts b/packages/react-query/src/suspense.ts index 60679a09ce..6fc01ea687 100644 --- a/packages/react-query/src/suspense.ts +++ b/packages/react-query/src/suspense.ts @@ -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, +) => typeof query.state.data === 'undefined' + export const ensureStaleTime = ( defaultedOptions: DefaultedQueryObserverOptions, ) => { diff --git a/packages/react-query/src/useSuspenseQueries.ts b/packages/react-query/src/useSuspenseQueries.ts index 6c940e17d1..8c201695b7 100644 --- a/packages/react-query/src/useSuspenseQueries.ts +++ b/packages/react-query/src/useSuspenseQueries.ts @@ -1,5 +1,6 @@ 'use client' import { useQueries } from './useQueries' +import { defaultThrowOnError } from './suspense' import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types' import type { DefaultError, @@ -154,7 +155,7 @@ export function useSuspenseQueries< queries: options.queries.map((query) => ({ ...query, suspense: true, - throwOnError: true, + throwOnError: defaultThrowOnError, enabled: true, })), } as any, diff --git a/packages/react-query/src/useSuspenseQuery.ts b/packages/react-query/src/useSuspenseQuery.ts index 1b25892542..179f58392a 100644 --- a/packages/react-query/src/useSuspenseQuery.ts +++ b/packages/react-query/src/useSuspenseQuery.ts @@ -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' @@ -18,7 +19,7 @@ export function useSuspenseQuery< ...options, enabled: true, suspense: true, - throwOnError: true, + throwOnError: defaultThrowOnError, }, QueryObserver, queryClient,