[Fizz] Unblock SuspenseList when prerendering (#33321)
There's an interesting case when a SuspenseList is partially prerendered but some of the completed boundaries are blocked by rows to be resumed. This handles it but just unblocking the future rows to avoid stalling. However, the correct semantics will need special handling in the postponed state.
Sebastian Markbåge committed
May 21, 2025 at 15:31 UTC
f4041aa388b7eeba94a26f6c273e9f4c18e2d2b3
2 files changed
+90
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+83
@@ -29,6 +29,7 @@ let ReactDOM;
29
let ReactDOMFizzServer;
30
let ReactDOMFizzStatic;
31
let Suspense;
32
+let SuspenseList;
33
let container;
34
let Scheduler;
35
let act;
@@ -50,6 +51,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
51
ReactDOMFizzServer = require('react-dom/server.browser');
52
ReactDOMFizzStatic = require('react-dom/static.browser');
53
Suspense = React.Suspense;
54
+ SuspenseList = React.unstable_SuspenseList;
55
container = document.createElement('div');
56
document.body.appendChild(container);
57
});
@@ -2242,4 +2244,85 @@ describe('ReactDOMFizzStaticBrowser', () => {
2244
</html>,
2245
);
2246
});
2247
+
2248
+ // @gate enableHalt && enableSuspenseList
2249
+ it('can resume a partially prerendered SuspenseList', async () => {
2250
+ const errors = [];
2251
+
2252
+ let resolveA;
2253
+ const promiseA = new Promise(r => (resolveA = r));
2254
+ let resolveB;
2255
+ const promiseB = new Promise(r => (resolveB = r));
2256
+
2257
+ async function ComponentA() {
2258
+ await promiseA;
2259
+ return 'A';
2260
+ }
2261
+
2262
+ async function ComponentB() {
2263
+ await promiseB;
2264
+ return 'B';
2265
+ }
2266
+
2267
+ function App() {
2268
+ return (
2269
+ <div>
2270
+ <SuspenseList revealOrder="forwards">
2271
+ <Suspense fallback="Loading A">
2272
+ <ComponentA />
2273
+ </Suspense>
2274
+ <Suspense fallback="Loading B">
2275
+ <ComponentB />
2276
+ </Suspense>
2277
+ <Suspense fallback="Loading C">C</Suspense>
2278
+ </SuspenseList>
2279
+ </div>
2280
+ );
2281
+ }
2282
+
2283
+ const controller = new AbortController();
2284
+ const pendingResult = serverAct(() =>
2285
+ ReactDOMFizzStatic.prerender(<App />, {
2286
+ signal: controller.signal,
2287
+ onError(x) {
2288
+ errors.push(x.message);
2289
+ },
2290
+ }),
2291
+ );
2292
+
2293
+ await serverAct(() => {
2294
+ controller.abort();
2295
+ });
2296
+
2297
+ const prerendered = await pendingResult;
2298
+ const postponedState = JSON.stringify(prerendered.postponed);
2299
+
2300
+ await readIntoContainer(prerendered.prelude);
2301
+ expect(getVisibleChildren(container)).toEqual(
2302
+ <div>
2303
+ {'Loading A'}
2304
+ {'Loading B'}
2305
+ {'C' /* TODO: This should not be resolved. */}
2306
+ </div>,
2307
+ );
2308
+
2309
+ expect(prerendered.postponed).not.toBe(null);
2310
+
2311
+ await resolveA();
2312
+ await resolveB();
2313
+
2314
+ const dynamic = await serverAct(() =>
2315
+ ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState)),
2316
+ );
2317
+
2318
+ await readIntoContainer(dynamic);
2319
+
2320
+ expect(getVisibleChildren(container)).toEqual(
2321
+ <div>
2322
+ {'A'}
2323
+ {'B'}
2324
+ {'C'}
2325
+ </div>,
2326
+ );
2327
+ });
2328
});
packages/react-server/src/ReactFizzServer.js
+7
@@ -4938,6 +4938,13 @@ function finishedTask(
4938
// preparation work during the work phase rather than the when flushing.
4939
preparePreamble(request);
4940
}
4941
+ } else if (boundary.status === POSTPONED) {
4942
+ const boundaryRow = boundary.row;
4943
+ if (boundaryRow !== null) {
4944
+ if (--boundaryRow.pendingTasks === 0) {
4945
+ finishSuspenseListRow(request, boundaryRow);
4946
+ }
4947
+ }
4948
}
4949
} else {
4950
if (segment !== null && segment.parentFlushed) {