|
| 1 | +import * as React from "react"; |
| 2 | +import { SuspenseProps, SuspenseState } from "../models"; |
| 3 | + |
| 4 | +/** |
| 5 | + * Catches Promise throws from descendants and schedules a re-render once |
| 6 | + * the Promise settles. This is the core mechanism that makes the Suspense |
| 7 | + * polyfill work: React's error boundary lifecycle (`componentDidCatch`) is |
| 8 | + * repurposed to intercept thrown Promises instead of thrown Errors. |
| 9 | + */ |
| 10 | +class SuspenseBoundary extends React.Component<SuspenseProps, SuspenseState> { |
| 11 | + public state: SuspenseState = { |
| 12 | + isSuspended: false, |
| 13 | + suspendedPromise: null, |
| 14 | + }; |
| 15 | + |
| 16 | + /** |
| 17 | + * `getDerivedStateFromError` is called synchronously during rendering when |
| 18 | + * a descendant throws. If the thrown value is a Promise (the Suspense |
| 19 | + * contract), we mark the boundary as suspended. If it is a real Error we |
| 20 | + * re-throw it so it can be caught by a parent `ErrorBoundary`. |
| 21 | + */ |
| 22 | + public static getDerivedStateFromError(error: unknown): Partial<SuspenseState> | null { |
| 23 | + if (error instanceof Promise) { |
| 24 | + return { isSuspended: true, suspendedPromise: error }; |
| 25 | + } |
| 26 | + // Not a Promise — let it propagate to an ErrorBoundary above. |
| 27 | + throw error; |
| 28 | + } |
| 29 | + |
| 30 | + /** |
| 31 | + * Once the boundary has caught a Promise, we attach a `.then` handler so |
| 32 | + * that when the Promise resolves (data ready, lazy import loaded, etc.) we |
| 33 | + * reset state and trigger a re-render, allowing React to attempt rendering |
| 34 | + * the children again. |
| 35 | + */ |
| 36 | + public componentDidUpdate(_: SuspenseProps, prevState: SuspenseState): void { |
| 37 | + const { isSuspended, suspendedPromise } = this.state; |
| 38 | + if (isSuspended && suspendedPromise && suspendedPromise !== prevState.suspendedPromise) { |
| 39 | + suspendedPromise.then( |
| 40 | + () => this.setState({ isSuspended: false, suspendedPromise: null }), |
| 41 | + () => this.setState({ isSuspended: false, suspendedPromise: null }), |
| 42 | + ); |
| 43 | + } |
| 44 | + } |
| 45 | + |
| 46 | + public render(): React.ReactNode { |
| 47 | + if (this.state.isSuspended) { |
| 48 | + return <>{this.props.fallback}</>; |
| 49 | + } |
| 50 | + return <>{this.props.children}</>; |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +/** |
| 55 | + * **`Suspense`**: Polyfill of the React `<Suspense>` component. |
| 56 | + * |
| 57 | + * Renders `children` normally. When a descendant component suspends by |
| 58 | + * throwing a Promise (the standard Suspense contract used by `React.lazy`, |
| 59 | + * data-fetching libraries such as SWR, React Query, and Relay, and the |
| 60 | + * `use()` hook), `fallback` is rendered in its place until the Promise |
| 61 | + * resolves, at which point `children` are rendered again. |
| 62 | + * |
| 63 | + * ### How it works |
| 64 | + * |
| 65 | + * The polyfill relies on the same mechanism used by `ErrorBoundary`: |
| 66 | + * `getDerivedStateFromError` is called synchronously when any descendant |
| 67 | + * throws during rendering. If the thrown value is a `Promise`, the boundary |
| 68 | + * marks itself as suspended and renders `fallback`. Once the Promise settles |
| 69 | + * (resolved or rejected), the boundary resets and React re-renders |
| 70 | + * `children`. |
| 71 | + * |
| 72 | + * ### Differences from native React `<Suspense>` |
| 73 | + * |
| 74 | + * - **No concurrent rendering**: the native `<Suspense>` integrates with |
| 75 | + * React's concurrent mode to render suspended subtrees in the background |
| 76 | + * without blocking the UI. This polyfill performs a synchronous |
| 77 | + * `setState` on Promise resolution, which may cause a brief flash of the |
| 78 | + * `fallback` even when the Promise resolves immediately. |
| 79 | + * - **No `startTransition` integration**: the native `<Suspense>` can keep |
| 80 | + * the previous UI visible during a transition while the new subtree loads. |
| 81 | + * This polyfill always shows `fallback` immediately on suspend. |
| 82 | + * - **No streaming / server-side rendering support**: the native `<Suspense>` |
| 83 | + * supports streaming HTML from the server. This polyfill is client-only. |
| 84 | + * - **Promise rejection**: when a suspended Promise rejects, this polyfill |
| 85 | + * resets the suspended state and re-renders `children`, which will throw |
| 86 | + * again — resulting in an infinite loop unless the child handles the error |
| 87 | + * internally. Wrap with an `ErrorBoundary` to handle rejection gracefully. |
| 88 | + * |
| 89 | + * @see [React Suspense docs](https://react.dev/reference/react/Suspense) |
| 90 | + * @see [📖 Documentation](https://react-tools.ndria.dev/components/Suspense) |
| 91 | + * @param {SuspenseProps} props - {@link SuspenseProps} |
| 92 | + * @returns {JSX.Element} element |
| 93 | + */ |
| 94 | +export const Suspense: React.ComponentType<SuspenseProps> = |
| 95 | + (React as any).Suspense ?? SuspenseBoundary; |
0 commit comments