Skip to content

Commit 173739c

Browse files
committed
feat: Suspense component polyfill
1 parent 4e2b620 commit 173739c

3 files changed

Lines changed: 236 additions & 0 deletions

File tree

src/components/Suspense.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { useState } from "react";
2+
import { Suspense } from "../../..";
3+
4+
/**
5+
The component demonstrates the _Suspense_ polyfill with two scenarios:
6+
- **Lazy component**: a button triggers the lazy load of a component via `React.lazy`. While loading, a spinner fallback is shown. Once ready, the component appears.
7+
- **Data fetching**: a button triggers a simulated async data fetch (1.5s delay) using the Suspense throw-Promise contract. While fetching, a skeleton fallback is shown. Once resolved, the data is displayed.
8+
Both scenarios show the `fallback` prop in action and demonstrate that `Suspense` catches thrown Promises from descendant components.
9+
*/
10+
11+
// ---------------------------------------------------------------------------
12+
// Lazy component scenario
13+
// ---------------------------------------------------------------------------
14+
15+
const LazyChild = (() => {
16+
let loaded = false;
17+
let promise: Promise<void> | null = null;
18+
19+
return function LazyChild() {
20+
if (!loaded) {
21+
if (!promise) {
22+
promise = new Promise<void>(res => setTimeout(() => { loaded = true; res(); }, 1200));
23+
}
24+
throw promise;
25+
}
26+
return <p style={{ margin: 0, color: "green" }}>✓ Lazy component loaded!</p>;
27+
};
28+
})();
29+
30+
// ---------------------------------------------------------------------------
31+
// Data fetching scenario
32+
// ---------------------------------------------------------------------------
33+
34+
type Status = "idle" | "pending" | "done";
35+
36+
const createResource = (delay: number) => {
37+
let status: Status = "pending";
38+
let result = "";
39+
const promise = new Promise<string>(res =>
40+
setTimeout(() => { result = `Fetched at ${new Date().toLocaleTimeString()}`; status = "done"; res(result); }, delay)
41+
);
42+
return {
43+
read(): string {
44+
if (status === "pending") throw promise;
45+
return result;
46+
},
47+
};
48+
};
49+
50+
type Resource = ReturnType<typeof createResource>;
51+
52+
const DataChild = ({ resource }: { resource: Resource }) => {
53+
const data = resource.read();
54+
return <p style={{ margin: 0, color: "#1e88e5" }}>{data}</p>;
55+
};
56+
57+
// ---------------------------------------------------------------------------
58+
// Demo component
59+
// ---------------------------------------------------------------------------
60+
61+
export default function SuspenseDemo() {
62+
const [showLazy, setShowLazy] = useState(false);
63+
const [resource, setResource] = useState<Resource | null>(null);
64+
65+
return (
66+
<div style={{ display: "grid", gap: 24, maxWidth: 420, margin: "0 auto" }}>
67+
68+
{/* Lazy component */}
69+
<div style={{ border: "1px solid #e0e0e0", borderRadius: 8, padding: 16 }}>
70+
<p style={{ margin: "0 0 12px", fontWeight: "bold" }}>Lazy component</p>
71+
<button onClick={() => setShowLazy(true)} disabled={showLazy}>
72+
Load component
73+
</button>
74+
{showLazy && (
75+
<div style={{ marginTop: 12 }}>
76+
<Suspense fallback={<p style={{ margin: 0, color: "#999" }}>⏳ Loading…</p>}>
77+
<LazyChild />
78+
</Suspense>
79+
</div>
80+
)}
81+
</div>
82+
83+
{/* Data fetching */}
84+
<div style={{ border: "1px solid #e0e0e0", borderRadius: 8, padding: 16 }}>
85+
<p style={{ margin: "0 0 12px", fontWeight: "bold" }}>Data fetching</p>
86+
<button onClick={() => setResource(createResource(1500))}>
87+
{resource ? "Fetch again" : "Fetch data"}
88+
</button>
89+
{resource && (
90+
<div style={{ marginTop: 12 }}>
91+
<Suspense
92+
fallback={
93+
<div style={{ background: "#f5f5f5", borderRadius: 4, height: 20, width: "60%", margin: 0, color: 'black' }}>Fallback</div>
94+
}
95+
>
96+
<DataChild resource={resource} />
97+
</Suspense>
98+
</div>
99+
)}
100+
</div>
101+
102+
</div>
103+
);
104+
}

src/models/Suspense.model.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { PropsWithChildren, ReactNode } from "react";
2+
3+
/**
4+
* Internal state managed by the
5+
* [Suspense](https://react-tools.ndria.dev/components/Suspense) polyfill.
6+
*/
7+
export interface SuspenseState {
8+
/**
9+
* `true` when a descendant component has thrown a Promise that has not yet
10+
* resolved, causing the `fallback` UI to be rendered in its place.
11+
* Reset to `false` once the Promise resolves and the component tree is
12+
* ready to be rendered.
13+
*/
14+
isSuspended: boolean;
15+
16+
/**
17+
* The Promise thrown by a suspended descendant, or `null` when no
18+
* component is currently suspended. Used internally to schedule a
19+
* re-render once the Promise settles.
20+
*/
21+
suspendedPromise: Promise<unknown> | null;
22+
}
23+
24+
/**
25+
* Props accepted by the [Suspense](https://react-tools.ndria.dev/components/Suspense) polyfill.
26+
*/
27+
export interface SuspenseProps extends PropsWithChildren {
28+
/**
29+
* Content rendered while one or more descendant components are suspended
30+
* (i.e. while a thrown Promise is pending). Accepts any valid React node —
31+
* typically a spinner, skeleton, or loading message.
32+
*
33+
* When the suspended Promise resolves, `fallback` is replaced by the
34+
* original `children`.
35+
*/
36+
fallback: ReactNode;
37+
}

0 commit comments

Comments
 (0)