|
| 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 | +} |
0 commit comments