-
Notifications
You must be signed in to change notification settings - Fork 326
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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> ```
- Loading branch information
1 parent
6fe253f
commit 0c41e8d
Showing
10 changed files
with
310 additions
and
62 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
/** | ||
* @file | ||
* | ||
* Await a promise and render the children when the promise is resolved. | ||
*/ | ||
import { type ReactNode } from 'react' | ||
|
||
import invariant from 'tiny-invariant' | ||
import { ErrorBoundary, type ErrorBoundaryProps } from './ErrorBoundary' | ||
import { Suspense, type SuspenseProps } from './Suspense' | ||
|
||
/** | ||
* Props for the {@link Await} component. | ||
*/ | ||
export interface AwaitProps<PromiseType> | ||
extends Omit<SuspenseProps, 'children'>, | ||
Omit<ErrorBoundaryProps, 'children'> { | ||
/** | ||
* Promise to await. | ||
* | ||
* ___The promise instance ***must be stable***, otherwise this will lock the UI into the loading state___ | ||
*/ | ||
readonly promise: Promise<PromiseType> | ||
readonly children: ReactNode | ((value: PromiseType) => ReactNode) | ||
} | ||
|
||
/** | ||
* State of the promise. | ||
*/ | ||
export type PromiseState<T> = | ||
| { | ||
readonly status: 'error' | ||
readonly data?: never | ||
readonly error: unknown | ||
} | ||
| { | ||
readonly status: 'pending' | ||
readonly data?: never | ||
readonly error?: never | ||
} | ||
| { | ||
readonly status: 'success' | ||
readonly data: T | ||
readonly error?: never | ||
} | ||
|
||
/** | ||
* Awaits a promise and render the children when the promise resolves. | ||
* Works well with React Query, as it returns a cached promise from the useQuery hook. | ||
* Useful to trigger Suspense ***inside*** the component, rather than ***outside*** of it. | ||
* @example | ||
* const {promise} = useQuery({queryKey: ['data'], queryFn: fetchData}) | ||
* | ||
* <Await promise={promise}> | ||
* {(data) => <div>{data}</div>} | ||
* </Await> | ||
*/ | ||
export function Await<PromiseType>(props: AwaitProps<PromiseType>) { | ||
const { | ||
promise, | ||
children, | ||
FallbackComponent, | ||
fallback, | ||
loaderProps, | ||
onBeforeFallbackShown, | ||
onError, | ||
onReset, | ||
resetKeys, | ||
subtitle, | ||
title, | ||
} = props | ||
|
||
return ( | ||
<ErrorBoundary | ||
FallbackComponent={FallbackComponent} | ||
onError={onError} | ||
onBeforeFallbackShown={onBeforeFallbackShown} | ||
onReset={onReset} | ||
resetKeys={resetKeys} | ||
subtitle={subtitle} | ||
title={title} | ||
> | ||
<Suspense fallback={fallback} loaderProps={loaderProps}> | ||
<AwaitInternal promise={promise} children={children} /> | ||
</Suspense> | ||
</ErrorBoundary> | ||
) | ||
} | ||
|
||
const PRIVATE_AWAIT_PROMISE_STATE = Symbol('PRIVATE_AWAIT_PROMISE_STATE_REF') | ||
|
||
/** | ||
* Internal implementation of the {@link Await} component. | ||
* | ||
* This component throws the promise and trigger the Suspense boundary | ||
* inside the {@link Await} component. | ||
* @throws {Promise} - The promise that is being awaited by Suspense. | ||
* @throws {unknown} - The error that is being thrown by the promise. Triggers error boundary inside the {@link Await} component. | ||
*/ | ||
function AwaitInternal<PromiseType>(props: AwaitProps<PromiseType>) { | ||
const { promise, children } = props | ||
|
||
/** | ||
* Define the promise state on the promise. | ||
*/ | ||
const definePromiseState = ( | ||
promiseToDefineOn: Promise<PromiseType>, | ||
promiseState: PromiseState<PromiseType>, | ||
) => { | ||
// @ts-expect-error: we know that the promise state is not defined in the type but it's fine, | ||
// because it's a private and scoped to the component. | ||
promiseToDefineOn[PRIVATE_AWAIT_PROMISE_STATE] = promiseState | ||
} | ||
|
||
// We need to define the promise state, only once. | ||
// We don't want to use refs on state, because it scopes the state to the component. | ||
// But we might use multiple Await components with the same promise. | ||
if (!(PRIVATE_AWAIT_PROMISE_STATE in promise)) { | ||
definePromiseState(promise, { status: 'pending' }) | ||
|
||
// This breaks the chain of promises, but it's fine, | ||
// because this is suppsed to the last in the chain. | ||
// and the error will be thrown in the render phase | ||
// to trigger the error boundary. | ||
void promise.then((data) => { | ||
definePromiseState(promise, { status: 'success', data }) | ||
}) | ||
void promise.catch((error) => { | ||
definePromiseState(promise, { status: 'error', error }) | ||
}) | ||
} | ||
|
||
// This should never happen, as the promise state is defined above. | ||
// But we need to check it, because the promise state is not defined in the type. | ||
// And we want to make TypeScript happy. | ||
invariant( | ||
PRIVATE_AWAIT_PROMISE_STATE in promise, | ||
'Promise state is not defined. This should never happen.', | ||
) | ||
|
||
const promiseState = | ||
// This is safe, as we defined the promise state above. | ||
// and it always present in the promise object. | ||
// eslint-disable-next-line no-restricted-syntax | ||
promise[PRIVATE_AWAIT_PROMISE_STATE] as PromiseState<PromiseType> | ||
|
||
if (promiseState.status === 'pending') { | ||
// Throwing a promise is the valid way to trigger Suspense | ||
// eslint-disable-next-line @typescript-eslint/only-throw-error | ||
throw promise | ||
} | ||
|
||
if (promiseState.status === 'error') { | ||
throw promiseState.error | ||
} | ||
|
||
return typeof children === 'function' ? children(promiseState.data) : children | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import { act, render, screen } from '@testing-library/react' | ||
import { describe, vi } from 'vitest' | ||
import { Await } from '../Await' | ||
|
||
describe('<Await />', (it) => { | ||
it('should the suspense boundary before promise is resolved, then show the children once promise is resolved', async ({ | ||
expect, | ||
}) => { | ||
const promise = Promise.resolve('Hello') | ||
render(<Await promise={promise}>{(value) => <div>{value}</div>}</Await>) | ||
|
||
expect(screen.queryByText('Hello')).not.toBeInTheDocument() | ||
expect(screen.getByTestId('spinner')).toBeInTheDocument() | ||
|
||
await act(() => promise) | ||
|
||
expect(screen.getByText('Hello')).toBeInTheDocument() | ||
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument() | ||
}) | ||
|
||
// This test is SUPPOSED to throw an error, | ||
// Because the only way to test the error boundary is to throw an error during the render phase. | ||
// But currently, vitest fails when promise is rejected with ⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯ output, | ||
// and it causes the test to fail on CI. | ||
// We do not want to catch the error before we render the component, | ||
// because in that case, the error boundary will not be triggered. | ||
// This can be avoided by setting `dangerouslyIgnoreUnhandledErrors` to true in the vitest config, | ||
// but it's unsafe to do for all tests, and there's no way to do it for a single test. | ||
// We skip this test for now on CI, until we find a way to fix it. | ||
it.skipIf(process.env.CI)( | ||
'should show the fallback if the promise is rejected', | ||
async ({ expect }) => { | ||
// Suppress the error message from the console caused by React Error Boundary | ||
vi.spyOn(console, 'error').mockImplementation(() => {}) | ||
|
||
const promise = Promise.reject(new Error('💣')) | ||
|
||
render(<Await promise={promise}>{() => <>Hello</>}</Await>) | ||
|
||
expect(screen.getByTestId('spinner')).toBeInTheDocument() | ||
|
||
await act(() => promise.catch(() => {})) | ||
|
||
expect(screen.queryByText('Hello')).not.toBeInTheDocument() | ||
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument() | ||
expect(screen.getByTestId('error-display')).toBeInTheDocument() | ||
// eslint-disable-next-line no-restricted-properties | ||
expect(console.error).toHaveBeenCalled() | ||
}, | ||
) | ||
|
||
it('should not display the Suspense boundary of the second Await if the first Await already resolved', async ({ | ||
expect, | ||
}) => { | ||
const promise = Promise.resolve('Hello') | ||
const { unmount } = render(<Await promise={promise}>{(value) => <div>{value}</div>}</Await>) | ||
|
||
await act(() => promise) | ||
|
||
expect(screen.getByText('Hello')).toBeInTheDocument() | ||
|
||
unmount() | ||
|
||
render(<Await promise={promise}>{(value) => <div>{value}</div>}</Await>) | ||
|
||
expect(screen.getByText('Hello')).toBeInTheDocument() | ||
}) | ||
}) |
Oops, something went wrong.