Skip to content

Commit e9a9590

Browse files
chore: React Suspense tests (#29)
1 parent 6ad8aca commit e9a9590

2 files changed

Lines changed: 280 additions & 3 deletions

File tree

src/__tests__/suspense.test.tsx

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import { beforeEach, expect, test } from "@jest/globals";
2+
import { Suspense, use } from "react";
3+
4+
import { createRoot } from "../renderer";
5+
import { act, renderWithAct } from "../test-utils/render";
6+
7+
beforeEach(() => {
8+
// @ts-expect-error global is not typed
9+
global.IS_REACT_ACT_ENVIRONMENT = true;
10+
});
11+
12+
test("Suspense renders content directly when no async operation", async () => {
13+
const renderer = createRoot();
14+
await renderWithAct(
15+
renderer,
16+
<Suspense fallback={<div>Loading...</div>}>
17+
<div>Content</div>
18+
</Suspense>,
19+
);
20+
21+
expect(renderer.container).toMatchInlineSnapshot(`
22+
<>
23+
<div>
24+
Content
25+
</div>
26+
</>
27+
`);
28+
});
29+
30+
test("Suspense shows fallback and then content", async () => {
31+
let resolvePromise: (value: string) => void;
32+
const promise = new Promise<string>((resolve) => {
33+
resolvePromise = resolve;
34+
});
35+
36+
function AsyncComponent() {
37+
const data = use(promise);
38+
return <div>Data: {data}</div>;
39+
}
40+
41+
const renderer = createRoot();
42+
43+
await renderWithAct(
44+
renderer,
45+
<Suspense fallback={<div>Loading...</div>}>
46+
<AsyncComponent />
47+
</Suspense>,
48+
);
49+
50+
expect(renderer.container).toMatchInlineSnapshot(`
51+
<>
52+
<div>
53+
Loading...
54+
</div>
55+
</>
56+
`);
57+
58+
await act(() => {
59+
resolvePromise!("Hello World");
60+
});
61+
62+
expect(renderer.container).toMatchInlineSnapshot(`
63+
<>
64+
<div>
65+
Data:
66+
Hello World
67+
</div>
68+
</>
69+
`);
70+
});
71+
72+
test("Suspense waits for all promises to resolve", async () => {
73+
let resolvePromise1: (value: string) => void;
74+
let resolvePromise2: (value: number) => void;
75+
76+
const promise1 = new Promise<string>((resolve) => {
77+
resolvePromise1 = resolve;
78+
});
79+
80+
const promise2 = new Promise<number>((resolve) => {
81+
resolvePromise2 = resolve;
82+
});
83+
84+
function AsyncComponent1() {
85+
const data = use(promise1);
86+
return <span>First: {data}</span>;
87+
}
88+
89+
function AsyncComponent2() {
90+
const data = use(promise2);
91+
return <span>Second: {data}</span>;
92+
}
93+
94+
const renderer = createRoot();
95+
96+
await renderWithAct(
97+
renderer,
98+
<Suspense fallback={<div>Loading...</div>}>
99+
<AsyncComponent1 />
100+
<AsyncComponent2 />
101+
</Suspense>,
102+
);
103+
104+
expect(renderer.container).toMatchInlineSnapshot(`
105+
<>
106+
<div>
107+
Loading...
108+
</div>
109+
</>
110+
`);
111+
112+
await act(() => {
113+
resolvePromise1!("Alpha");
114+
});
115+
116+
expect(renderer.container).toMatchInlineSnapshot(`
117+
<>
118+
<div>
119+
Loading...
120+
</div>
121+
</>
122+
`);
123+
124+
await act(() => {
125+
resolvePromise2!(42);
126+
});
127+
128+
expect(renderer.container).toMatchInlineSnapshot(`
129+
<>
130+
<span>
131+
First:
132+
Alpha
133+
</span>
134+
<span>
135+
Second:
136+
42
137+
</span>
138+
</>
139+
`);
140+
});
141+
142+
test("React.use with already resolved promise", async () => {
143+
const resolvedPromise = Promise.resolve("Resolved");
144+
145+
function AsyncComponent() {
146+
const data = use(resolvedPromise);
147+
return <div>{data}</div>;
148+
}
149+
150+
const renderer = createRoot();
151+
await renderWithAct(
152+
renderer,
153+
<Suspense fallback={<div>Loading...</div>}>
154+
<AsyncComponent />
155+
</Suspense>,
156+
);
157+
158+
expect(renderer.container).toMatchInlineSnapshot(`
159+
<>
160+
<div>
161+
Resolved
162+
</div>
163+
</>
164+
`);
165+
});
166+
167+
test("React.use with rejected promise throws error", async () => {
168+
const rejectedPromise = Promise.reject(new Error("Test error"));
169+
170+
function AsyncComponent() {
171+
// React.use throws a Suspense exception that cannot be caught in try/catch
172+
// The error will propagate to the nearest error boundary or cause the render to fail
173+
const data = use(rejectedPromise);
174+
return <div>{data}</div>;
175+
}
176+
177+
const renderer = createRoot();
178+
179+
await expect(
180+
renderWithAct(
181+
renderer,
182+
<Suspense fallback={<div>Loading...</div>}>
183+
<AsyncComponent />
184+
</Suspense>,
185+
),
186+
).rejects.toThrowErrorMatchingInlineSnapshot(`"Test error"`);
187+
});
188+
189+
test("Suspense with nested boundaries", async () => {
190+
let resolveOuter: (value: string) => void;
191+
let resolveInner: (value: string) => void;
192+
193+
const outerPromise = new Promise<string>((resolve) => {
194+
resolveOuter = resolve;
195+
});
196+
197+
const innerPromise = new Promise<string>((resolve) => {
198+
resolveInner = resolve;
199+
});
200+
201+
function OuterComponent() {
202+
const data = use(outerPromise);
203+
return (
204+
<div>
205+
Outer: {data}
206+
<Suspense fallback={<span>Inner loading...</span>}>
207+
<InnerComponent />
208+
</Suspense>
209+
</div>
210+
);
211+
}
212+
213+
function InnerComponent() {
214+
const data = use(innerPromise);
215+
return <span>Inner: {data}</span>;
216+
}
217+
218+
const renderer = createRoot();
219+
220+
await renderWithAct(
221+
renderer,
222+
<Suspense fallback={<div>Outer loading...</div>}>
223+
<OuterComponent />
224+
</Suspense>,
225+
);
226+
227+
// Initially should show outer fallback
228+
expect(renderer.container).toMatchInlineSnapshot(`
229+
<>
230+
<div>
231+
Outer loading...
232+
</div>
233+
</>
234+
`);
235+
236+
// Resolve outer promise
237+
await act(async () => {
238+
resolveOuter!("Outer Data");
239+
await outerPromise;
240+
});
241+
242+
// Should show outer content with inner fallback
243+
expect(renderer.container).toMatchInlineSnapshot(`
244+
<>
245+
<div>
246+
Outer:
247+
Outer Data
248+
<span>
249+
Inner loading...
250+
</span>
251+
</div>
252+
</>
253+
`);
254+
255+
// Resolve inner promise
256+
await act(async () => {
257+
resolveInner!("Inner Data");
258+
await innerPromise;
259+
});
260+
261+
// Should show both resolved
262+
expect(renderer.container).toMatchInlineSnapshot(`
263+
<>
264+
<div>
265+
Outer:
266+
Outer Data
267+
<span>
268+
Inner:
269+
Inner Data
270+
</span>
271+
</div>
272+
</>
273+
`);
274+
});

src/test-utils/render.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import type { ReactElement } from "react";
2-
import { act } from "react";
2+
import { act as reactAct } from "react";
33

44
import type { HostElement } from "../host-element";
55
import type { Root } from "../renderer";
66

7+
export async function act<T>(callback: () => T | Promise<T>): Promise<T> {
8+
return await reactAct(async () => callback());
9+
}
10+
711
export async function renderWithAct(root: Root, element: ReactElement) {
8-
// eslint-disable-next-line @typescript-eslint/require-await -- intentionally triggering async act variant
9-
await act(async () => {
12+
await act(() => {
1013
root.render(element);
1114
});
1215
}

0 commit comments

Comments
 (0)