Skip to content

Commit 0c41e8d

Browse files
Await component (#11936)
This PR adds an `<Await />` component, that allows to trigger `Suspense` ___inside the component___ ```tsx const {promise} = useQuery({queryKey: ['data'], queryFn: fetchData}) // Will trigger the suspense within this component // And once the promise resolves, call the children with the data // unlike regular approach like `return isLoading ? <Loader /> : <div>{data}</div>` // it doesn't lead to waterfall of loaders and tell React to keep the tree suspended // So we always have a single loader, which improves the UX <Await promise={promise}> {(data) => <div>{data}</div>} </Await> ```
1 parent 6fe253f commit 0c41e8d

File tree

10 files changed

+310
-62
lines changed

10 files changed

+310
-62
lines changed

app/common/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@
3333
"lint": "eslint ./src --cache --max-warnings=0"
3434
},
3535
"peerDependencies": {
36-
"@tanstack/query-core": "5.54.1",
37-
"@tanstack/vue-query": ">= 5.54.0 < 5.56.0"
36+
"@tanstack/query-core": "5.59.20",
37+
"@tanstack/vue-query": "5.59.20"
3838
},
3939
"dependencies": {
40-
"@tanstack/query-persist-client-core": "^5.54.0",
41-
"@tanstack/vue-query": ">= 5.54.0 < 5.56.0",
40+
"@tanstack/query-persist-client-core": "5.59.20",
41+
"@tanstack/vue-query": "5.59.20",
4242
"lib0": "^0.2.85",
4343
"react": "^18.3.1",
4444
"vitest": "^1.3.1",

app/common/src/queryClient.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ export function createQueryClient<TStorageValue = string>(
135135
networkMode: 'always',
136136
refetchOnReconnect: 'always',
137137
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
138+
// This allows to prefetch queries in the render phase. Enables returning
139+
// a promise from the `useQuery` hook, which is useful for the `Await` component,
140+
// which needs to prefetch the query in the render phase to be able to display
141+
// the error boundary/suspense fallback.
142+
// @see [experimental_prefetchInRender](https://tanstack.com/query/latest/docs/framework/react/guides/suspense#using-usequerypromise-and-reactuse-experimental)
143+
// eslint-disable-next-line camelcase
144+
experimental_prefetchInRender: true,
138145
retry: (failureCount, error: unknown) => {
139146
const statusesToIgnore = [403, 404]
140147
const errorStatus =

app/gui/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@
6565
"@sentry/vite-plugin": "^2.22.7",
6666
"@stripe/react-stripe-js": "^2.7.1",
6767
"@stripe/stripe-js": "^3.5.0",
68-
"@tanstack/react-query": "5.55.0",
69-
"@tanstack/vue-query": ">= 5.54.0 < 5.56.0",
68+
"@tanstack/react-query": "5.59.20",
69+
"@tanstack/vue-query": "5.59.20",
7070
"@vueuse/core": "^10.4.1",
7171
"@vueuse/gesture": "^2.0.0",
7272
"ag-grid-community": "^32.3.3",
@@ -155,7 +155,7 @@
155155
"@storybook/test": "^8.4.2",
156156
"@storybook/vue3": "^8.4.2",
157157
"@storybook/vue3-vite": "^8.4.2",
158-
"@tanstack/react-query-devtools": "5.45.1",
158+
"@tanstack/react-query-devtools": "5.59.20",
159159
"@testing-library/jest-dom": "6.6.3",
160160
"@testing-library/react": "16.0.1",
161161
"@testing-library/react-hooks": "8.0.1",
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* @file
3+
*
4+
* Await a promise and render the children when the promise is resolved.
5+
*/
6+
import { type ReactNode } from 'react'
7+
8+
import invariant from 'tiny-invariant'
9+
import { ErrorBoundary, type ErrorBoundaryProps } from './ErrorBoundary'
10+
import { Suspense, type SuspenseProps } from './Suspense'
11+
12+
/**
13+
* Props for the {@link Await} component.
14+
*/
15+
export interface AwaitProps<PromiseType>
16+
extends Omit<SuspenseProps, 'children'>,
17+
Omit<ErrorBoundaryProps, 'children'> {
18+
/**
19+
* Promise to await.
20+
*
21+
* ___The promise instance ***must be stable***, otherwise this will lock the UI into the loading state___
22+
*/
23+
readonly promise: Promise<PromiseType>
24+
readonly children: ReactNode | ((value: PromiseType) => ReactNode)
25+
}
26+
27+
/**
28+
* State of the promise.
29+
*/
30+
export type PromiseState<T> =
31+
| {
32+
readonly status: 'error'
33+
readonly data?: never
34+
readonly error: unknown
35+
}
36+
| {
37+
readonly status: 'pending'
38+
readonly data?: never
39+
readonly error?: never
40+
}
41+
| {
42+
readonly status: 'success'
43+
readonly data: T
44+
readonly error?: never
45+
}
46+
47+
/**
48+
* Awaits a promise and render the children when the promise resolves.
49+
* Works well with React Query, as it returns a cached promise from the useQuery hook.
50+
* Useful to trigger Suspense ***inside*** the component, rather than ***outside*** of it.
51+
* @example
52+
* const {promise} = useQuery({queryKey: ['data'], queryFn: fetchData})
53+
*
54+
* <Await promise={promise}>
55+
* {(data) => <div>{data}</div>}
56+
* </Await>
57+
*/
58+
export function Await<PromiseType>(props: AwaitProps<PromiseType>) {
59+
const {
60+
promise,
61+
children,
62+
FallbackComponent,
63+
fallback,
64+
loaderProps,
65+
onBeforeFallbackShown,
66+
onError,
67+
onReset,
68+
resetKeys,
69+
subtitle,
70+
title,
71+
} = props
72+
73+
return (
74+
<ErrorBoundary
75+
FallbackComponent={FallbackComponent}
76+
onError={onError}
77+
onBeforeFallbackShown={onBeforeFallbackShown}
78+
onReset={onReset}
79+
resetKeys={resetKeys}
80+
subtitle={subtitle}
81+
title={title}
82+
>
83+
<Suspense fallback={fallback} loaderProps={loaderProps}>
84+
<AwaitInternal promise={promise} children={children} />
85+
</Suspense>
86+
</ErrorBoundary>
87+
)
88+
}
89+
90+
const PRIVATE_AWAIT_PROMISE_STATE = Symbol('PRIVATE_AWAIT_PROMISE_STATE_REF')
91+
92+
/**
93+
* Internal implementation of the {@link Await} component.
94+
*
95+
* This component throws the promise and trigger the Suspense boundary
96+
* inside the {@link Await} component.
97+
* @throws {Promise} - The promise that is being awaited by Suspense.
98+
* @throws {unknown} - The error that is being thrown by the promise. Triggers error boundary inside the {@link Await} component.
99+
*/
100+
function AwaitInternal<PromiseType>(props: AwaitProps<PromiseType>) {
101+
const { promise, children } = props
102+
103+
/**
104+
* Define the promise state on the promise.
105+
*/
106+
const definePromiseState = (
107+
promiseToDefineOn: Promise<PromiseType>,
108+
promiseState: PromiseState<PromiseType>,
109+
) => {
110+
// @ts-expect-error: we know that the promise state is not defined in the type but it's fine,
111+
// because it's a private and scoped to the component.
112+
promiseToDefineOn[PRIVATE_AWAIT_PROMISE_STATE] = promiseState
113+
}
114+
115+
// We need to define the promise state, only once.
116+
// We don't want to use refs on state, because it scopes the state to the component.
117+
// But we might use multiple Await components with the same promise.
118+
if (!(PRIVATE_AWAIT_PROMISE_STATE in promise)) {
119+
definePromiseState(promise, { status: 'pending' })
120+
121+
// This breaks the chain of promises, but it's fine,
122+
// because this is suppsed to the last in the chain.
123+
// and the error will be thrown in the render phase
124+
// to trigger the error boundary.
125+
void promise.then((data) => {
126+
definePromiseState(promise, { status: 'success', data })
127+
})
128+
void promise.catch((error) => {
129+
definePromiseState(promise, { status: 'error', error })
130+
})
131+
}
132+
133+
// This should never happen, as the promise state is defined above.
134+
// But we need to check it, because the promise state is not defined in the type.
135+
// And we want to make TypeScript happy.
136+
invariant(
137+
PRIVATE_AWAIT_PROMISE_STATE in promise,
138+
'Promise state is not defined. This should never happen.',
139+
)
140+
141+
const promiseState =
142+
// This is safe, as we defined the promise state above.
143+
// and it always present in the promise object.
144+
// eslint-disable-next-line no-restricted-syntax
145+
promise[PRIVATE_AWAIT_PROMISE_STATE] as PromiseState<PromiseType>
146+
147+
if (promiseState.status === 'pending') {
148+
// Throwing a promise is the valid way to trigger Suspense
149+
// eslint-disable-next-line @typescript-eslint/only-throw-error
150+
throw promise
151+
}
152+
153+
if (promiseState.status === 'error') {
154+
throw promiseState.error
155+
}
156+
157+
return typeof children === 'function' ? children(promiseState.data) : children
158+
}

app/gui/src/dashboard/components/ErrorBoundary.tsx

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import * as result from '#/components/Result'
1616
import { useEventCallback } from '#/hooks/eventCallbackHooks'
1717
import * as errorUtils from '#/utilities/error'
1818
import { OfflineError } from '#/utilities/HttpClient'
19+
import type { FallbackProps } from 'react-error-boundary'
1920
import SvgMask from './SvgMask'
2021

2122
// =====================
@@ -30,20 +31,28 @@ export interface OnBeforeFallbackShownArgs {
3031
}
3132

3233
/** Props for an {@link ErrorBoundary}. */
33-
export interface ErrorBoundaryProps
34-
extends Readonly<React.PropsWithChildren>,
35-
Readonly<
36-
Pick<
37-
errorBoundary.ErrorBoundaryProps,
38-
'FallbackComponent' | 'onError' | 'onReset' | 'resetKeys'
39-
>
40-
> {
41-
/** Called before the fallback is shown. */
42-
readonly onBeforeFallbackShown?: (
43-
args: OnBeforeFallbackShownArgs,
44-
) => React.ReactNode | null | undefined
45-
readonly title?: string
46-
readonly subtitle?: string
34+
export interface ErrorBoundaryProps extends Readonly<React.PropsWithChildren> {
35+
/** Keys to reset the error boundary. Use it to declaratively reset the error boundary. */
36+
readonly resetKeys?: errorBoundary.ErrorBoundaryProps['resetKeys'] | undefined
37+
/** Fallback component to show when there is an error. */
38+
// This is a Component, and supposed to be capitalized according to the react conventions.
39+
// eslint-disable-next-line @typescript-eslint/naming-convention
40+
readonly FallbackComponent?: React.ComponentType<FallbackProps> | undefined
41+
/** Called when there is an error. */
42+
readonly onError?: errorBoundary.ErrorBoundaryProps['onError'] | undefined
43+
/** Called when the error boundary is reset. */
44+
readonly onReset?: errorBoundary.ErrorBoundaryProps['onReset'] | undefined
45+
/**
46+
* Called before the fallback is shown, can return a React node to render instead of the fallback.
47+
* Alternatively, you can use the error boundary api to reset the error boundary based on the error.
48+
*/
49+
readonly onBeforeFallbackShown?:
50+
| ((args: OnBeforeFallbackShownArgs) => React.ReactNode | null | undefined)
51+
| undefined
52+
/** Title to show when there is an error. */
53+
readonly title?: string | undefined
54+
/** Subtitle to show when there is an error. */
55+
readonly subtitle?: string | undefined
4756
}
4857

4958
/**
@@ -59,13 +68,15 @@ export function ErrorBoundary(props: ErrorBoundaryProps) {
5968
onBeforeFallbackShown = () => null,
6069
title,
6170
subtitle,
71+
resetKeys,
6272
...rest
6373
} = props
6474

6575
return (
6676
<reactQuery.QueryErrorResetBoundary>
6777
{({ reset }) => (
6878
<errorBoundary.ErrorBoundary
79+
{...(resetKeys != null ? { resetKeys } : {})}
6980
FallbackComponent={(fallbackProps) => {
7081
const displayMessage = errorUtils.extractDisplayMessage(fallbackProps.error)
7182

@@ -142,6 +153,7 @@ export function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element {
142153
status={finalStatus}
143154
title={finalTitle}
144155
subtitle={finalSubtitle}
156+
testId="error-display"
145157
>
146158
<ariaComponents.ButtonGroup align="center">
147159
<ariaComponents.Button

app/gui/src/dashboard/components/Result.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Success from '#/assets/check_mark.svg'
55
import Error from '#/assets/cross.svg'
66

77
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
8+
import type { TestIdProps } from './AriaComponents'
89
import { Text } from './AriaComponents/Text'
910
import * as loader from './Loader'
1011
import SvgMask from './SvgMask'
@@ -93,7 +94,10 @@ interface StatusIcon {
9394
// ==============
9495

9596
/** Props for a {@link Result}. */
96-
export interface ResultProps extends React.PropsWithChildren, VariantProps<typeof RESULT_STYLES> {
97+
export interface ResultProps
98+
extends React.PropsWithChildren,
99+
VariantProps<typeof RESULT_STYLES>,
100+
TestIdProps {
97101
readonly className?: string
98102
readonly title?: React.JSX.Element | string
99103
readonly subtitle?: React.JSX.Element | string
@@ -103,7 +107,6 @@ export interface ResultProps extends React.PropsWithChildren, VariantProps<typeo
103107
*/
104108
readonly status?: React.ReactElement | Status
105109
readonly icon?: string | false
106-
readonly testId?: string
107110
}
108111

109112
/** Display the result of an operation. */

app/gui/src/dashboard/components/Suspense.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import * as React from 'react'
1010
import * as loader from './Loader'
1111

1212
/** Props for {@link Suspense} component. */
13-
export interface SuspenseProps extends React.SuspenseProps {
14-
readonly loaderProps?: loader.LoaderProps
13+
export interface SuspenseProps extends React.PropsWithChildren {
14+
readonly fallback?: React.ReactNode | undefined
15+
readonly loaderProps?: loader.LoaderProps | undefined
1516
}
1617

1718
/**
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { act, render, screen } from '@testing-library/react'
2+
import { describe, vi } from 'vitest'
3+
import { Await } from '../Await'
4+
5+
describe('<Await />', (it) => {
6+
it('should the suspense boundary before promise is resolved, then show the children once promise is resolved', async ({
7+
expect,
8+
}) => {
9+
const promise = Promise.resolve('Hello')
10+
render(<Await promise={promise}>{(value) => <div>{value}</div>}</Await>)
11+
12+
expect(screen.queryByText('Hello')).not.toBeInTheDocument()
13+
expect(screen.getByTestId('spinner')).toBeInTheDocument()
14+
15+
await act(() => promise)
16+
17+
expect(screen.getByText('Hello')).toBeInTheDocument()
18+
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument()
19+
})
20+
21+
// This test is SUPPOSED to throw an error,
22+
// Because the only way to test the error boundary is to throw an error during the render phase.
23+
// But currently, vitest fails when promise is rejected with ⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯ output,
24+
// and it causes the test to fail on CI.
25+
// We do not want to catch the error before we render the component,
26+
// because in that case, the error boundary will not be triggered.
27+
// This can be avoided by setting `dangerouslyIgnoreUnhandledErrors` to true in the vitest config,
28+
// but it's unsafe to do for all tests, and there's no way to do it for a single test.
29+
// We skip this test for now on CI, until we find a way to fix it.
30+
it.skipIf(process.env.CI)(
31+
'should show the fallback if the promise is rejected',
32+
async ({ expect }) => {
33+
// Suppress the error message from the console caused by React Error Boundary
34+
vi.spyOn(console, 'error').mockImplementation(() => {})
35+
36+
const promise = Promise.reject(new Error('💣'))
37+
38+
render(<Await promise={promise}>{() => <>Hello</>}</Await>)
39+
40+
expect(screen.getByTestId('spinner')).toBeInTheDocument()
41+
42+
await act(() => promise.catch(() => {}))
43+
44+
expect(screen.queryByText('Hello')).not.toBeInTheDocument()
45+
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument()
46+
expect(screen.getByTestId('error-display')).toBeInTheDocument()
47+
// eslint-disable-next-line no-restricted-properties
48+
expect(console.error).toHaveBeenCalled()
49+
},
50+
)
51+
52+
it('should not display the Suspense boundary of the second Await if the first Await already resolved', async ({
53+
expect,
54+
}) => {
55+
const promise = Promise.resolve('Hello')
56+
const { unmount } = render(<Await promise={promise}>{(value) => <div>{value}</div>}</Await>)
57+
58+
await act(() => promise)
59+
60+
expect(screen.getByText('Hello')).toBeInTheDocument()
61+
62+
unmount()
63+
64+
render(<Await promise={promise}>{(value) => <div>{value}</div>}</Await>)
65+
66+
expect(screen.getByText('Hello')).toBeInTheDocument()
67+
})
68+
})

0 commit comments

Comments
 (0)