Skip to content

Commit 5dc1b21

Browse files
sebmarkbageeps1lon
andauthored
[Fizz] Support basic SuspenseList forwards/backwards revealOrder (#33306)
Basically we track a `SuspenseListRow` on the task. These keep track of "pending tasks" that block the row. A row is blocked by: - First itself completing rendering. - A previous row completing. - Any tasks inside the row and before the Suspense boundary inside the row. This is mainly because we don't yet know if we'll discover more SuspenseBoundaries. - Previous row's SuspenseBoundaries completing. If a boundary might get outlined, then we can't consider it completed until we have written it because it determined whether other future boundaries in the row can finish. This is just handling basic semantics. Features not supported yet that need follow ups later: - CSS dependencies of previous rows should be added as dependencies of future row's suspense boundary. Because otherwise if the client is blocked on CSS then a previous row could be blocked but the server doesn't know it. - I need a second pass on nested SuspenseList semantics. - `revealOrder="together"` - `tail="hidden"`/`tail="collapsed"`. This needs some new runtime semantics to the Fizz runtime and to allow the hydration to handle missing rows in the HTML. This should also be future compatible with AsyncIterable where we don't know how many rows upfront. - Need to double check resuming semantics. --------- Co-authored-by: Sebastian "Sebbie" Silbermann <[email protected]>
1 parent a3abf5f commit 5dc1b21

File tree

4 files changed

+792
-31
lines changed

4 files changed

+792
-31
lines changed

fixtures/ssr/src/components/LargeContent.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import React, {Fragment, Suspense} from 'react';
1+
import React, {
2+
Fragment,
3+
Suspense,
4+
unstable_SuspenseList as SuspenseList,
5+
} from 'react';
26

37
export default function LargeContent() {
48
return (
5-
<Fragment>
9+
<SuspenseList revealOrder="forwards">
610
<Suspense fallback={null}>
711
<p>
812
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris
@@ -286,6 +290,6 @@ export default function LargeContent() {
286290
interdum a. Proin nec odio in nulla vestibulum.
287291
</p>
288292
</Suspense>
289-
</Fragment>
293+
</SuspenseList>
290294
);
291295
}

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,10 +1318,8 @@ describe('ReactDOMFizzServer', () => {
13181318
expect(ref.current).toBe(null);
13191319
expect(getVisibleChildren(container)).toEqual(
13201320
<div>
1321-
Loading A
1322-
{/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
1323-
// isn't implemented fully yet. */}
1324-
<span>B</span>
1321+
{'Loading A'}
1322+
{'Loading B'}
13251323
</div>,
13261324
);
13271325

@@ -1335,11 +1333,9 @@ describe('ReactDOMFizzServer', () => {
13351333
// We haven't resolved yet.
13361334
expect(getVisibleChildren(container)).toEqual(
13371335
<div>
1338-
Loading A
1339-
{/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
1340-
// isn't implemented fully yet. */}
1341-
<span>B</span>
1342-
Loading C
1336+
{'Loading A'}
1337+
{'Loading B'}
1338+
{'Loading C'}
13431339
</div>,
13441340
);
13451341

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
9+
*/
10+
11+
'use strict';
12+
import {
13+
insertNodesAndExecuteScripts,
14+
getVisibleChildren,
15+
} from '../test-utils/FizzTestUtils';
16+
17+
let JSDOM;
18+
let React;
19+
let Suspense;
20+
let SuspenseList;
21+
let assertLog;
22+
let Scheduler;
23+
let ReactDOMFizzServer;
24+
let Stream;
25+
let document;
26+
let writable;
27+
let container;
28+
let buffer = '';
29+
let hasErrored = false;
30+
let fatalError = undefined;
31+
32+
describe('ReactDOMFizSuspenseList', () => {
33+
beforeEach(() => {
34+
jest.resetModules();
35+
JSDOM = require('jsdom').JSDOM;
36+
React = require('react');
37+
assertLog = require('internal-test-utils').assertLog;
38+
ReactDOMFizzServer = require('react-dom/server');
39+
Stream = require('stream');
40+
41+
Suspense = React.Suspense;
42+
SuspenseList = React.unstable_SuspenseList;
43+
44+
Scheduler = require('scheduler');
45+
46+
// Test Environment
47+
const jsdom = new JSDOM(
48+
'<!DOCTYPE html><html><head></head><body><div id="container">',
49+
{
50+
runScripts: 'dangerously',
51+
},
52+
);
53+
document = jsdom.window.document;
54+
container = document.getElementById('container');
55+
global.window = jsdom.window;
56+
// The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
57+
global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
58+
setTimeout(cb);
59+
60+
buffer = '';
61+
hasErrored = false;
62+
63+
writable = new Stream.PassThrough();
64+
writable.setEncoding('utf8');
65+
writable.on('data', chunk => {
66+
buffer += chunk;
67+
});
68+
writable.on('error', error => {
69+
hasErrored = true;
70+
fatalError = error;
71+
});
72+
});
73+
74+
afterEach(() => {
75+
jest.restoreAllMocks();
76+
});
77+
78+
async function serverAct(callback) {
79+
await callback();
80+
// Await one turn around the event loop.
81+
// This assumes that we'll flush everything we have so far.
82+
await new Promise(resolve => {
83+
setImmediate(resolve);
84+
});
85+
if (hasErrored) {
86+
throw fatalError;
87+
}
88+
// JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
89+
// We also want to execute any scripts that are embedded.
90+
// We assume that we have now received a proper fragment of HTML.
91+
const bufferedContent = buffer;
92+
buffer = '';
93+
const temp = document.createElement('body');
94+
temp.innerHTML = bufferedContent;
95+
await insertNodesAndExecuteScripts(temp, container, null);
96+
jest.runAllTimers();
97+
}
98+
99+
function Text(props) {
100+
Scheduler.log(props.text);
101+
return <span>{props.text}</span>;
102+
}
103+
104+
function createAsyncText(text) {
105+
let resolved = false;
106+
const Component = function () {
107+
if (!resolved) {
108+
Scheduler.log('Suspend! [' + text + ']');
109+
throw promise;
110+
}
111+
return <Text text={text} />;
112+
};
113+
const promise = new Promise(resolve => {
114+
Component.resolve = function () {
115+
resolved = true;
116+
return resolve();
117+
};
118+
});
119+
return Component;
120+
}
121+
122+
// @gate enableSuspenseList
123+
it('shows content independently by default', async () => {
124+
const A = createAsyncText('A');
125+
const B = createAsyncText('B');
126+
const C = createAsyncText('C');
127+
128+
function Foo() {
129+
return (
130+
<div>
131+
<SuspenseList>
132+
<Suspense fallback={<Text text="Loading A" />}>
133+
<A />
134+
</Suspense>
135+
<Suspense fallback={<Text text="Loading B" />}>
136+
<B />
137+
</Suspense>
138+
<Suspense fallback={<Text text="Loading C" />}>
139+
<C />
140+
</Suspense>
141+
</SuspenseList>
142+
</div>
143+
);
144+
}
145+
146+
await A.resolve();
147+
148+
await serverAct(async () => {
149+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
150+
pipe(writable);
151+
});
152+
153+
assertLog(['A', 'Suspend! [B]', 'Suspend! [C]', 'Loading B', 'Loading C']);
154+
155+
expect(getVisibleChildren(container)).toEqual(
156+
<div>
157+
<span>A</span>
158+
<span>Loading B</span>
159+
<span>Loading C</span>
160+
</div>,
161+
);
162+
163+
await serverAct(() => C.resolve());
164+
assertLog(['C']);
165+
166+
expect(getVisibleChildren(container)).toEqual(
167+
<div>
168+
<span>A</span>
169+
<span>Loading B</span>
170+
<span>C</span>
171+
</div>,
172+
);
173+
174+
await serverAct(() => B.resolve());
175+
assertLog(['B']);
176+
177+
expect(getVisibleChildren(container)).toEqual(
178+
<div>
179+
<span>A</span>
180+
<span>B</span>
181+
<span>C</span>
182+
</div>,
183+
);
184+
});
185+
186+
// @gate enableSuspenseList
187+
it('displays each items in "forwards" order', async () => {
188+
const A = createAsyncText('A');
189+
const B = createAsyncText('B');
190+
const C = createAsyncText('C');
191+
192+
function Foo() {
193+
return (
194+
<div>
195+
<SuspenseList revealOrder="forwards">
196+
<Suspense fallback={<Text text="Loading A" />}>
197+
<A />
198+
</Suspense>
199+
<Suspense fallback={<Text text="Loading B" />}>
200+
<B />
201+
</Suspense>
202+
<Suspense fallback={<Text text="Loading C" />}>
203+
<C />
204+
</Suspense>
205+
</SuspenseList>
206+
</div>
207+
);
208+
}
209+
210+
await C.resolve();
211+
212+
await serverAct(async () => {
213+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
214+
pipe(writable);
215+
});
216+
217+
assertLog([
218+
'Suspend! [A]',
219+
'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
220+
'C',
221+
'Loading A',
222+
'Loading B',
223+
'Loading C',
224+
]);
225+
226+
expect(getVisibleChildren(container)).toEqual(
227+
<div>
228+
<span>Loading A</span>
229+
<span>Loading B</span>
230+
<span>Loading C</span>
231+
</div>,
232+
);
233+
234+
await serverAct(() => A.resolve());
235+
assertLog(['A']);
236+
237+
expect(getVisibleChildren(container)).toEqual(
238+
<div>
239+
<span>A</span>
240+
<span>Loading B</span>
241+
<span>Loading C</span>
242+
</div>,
243+
);
244+
245+
await serverAct(() => B.resolve());
246+
assertLog(['B']);
247+
248+
expect(getVisibleChildren(container)).toEqual(
249+
<div>
250+
<span>A</span>
251+
<span>B</span>
252+
<span>C</span>
253+
</div>,
254+
);
255+
});
256+
257+
// @gate enableSuspenseList
258+
it('displays each items in "backwards" order', async () => {
259+
const A = createAsyncText('A');
260+
const B = createAsyncText('B');
261+
const C = createAsyncText('C');
262+
263+
function Foo() {
264+
return (
265+
<div>
266+
<SuspenseList revealOrder="backwards">
267+
<Suspense fallback={<Text text="Loading A" />}>
268+
<A />
269+
</Suspense>
270+
<Suspense fallback={<Text text="Loading B" />}>
271+
<B />
272+
</Suspense>
273+
<Suspense fallback={<Text text="Loading C" />}>
274+
<C />
275+
</Suspense>
276+
</SuspenseList>
277+
</div>
278+
);
279+
}
280+
281+
await A.resolve();
282+
283+
await serverAct(async () => {
284+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
285+
pipe(writable);
286+
});
287+
288+
assertLog([
289+
'Suspend! [C]',
290+
'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
291+
'A',
292+
'Loading C',
293+
'Loading B',
294+
'Loading A',
295+
]);
296+
297+
expect(getVisibleChildren(container)).toEqual(
298+
<div>
299+
<span>Loading A</span>
300+
<span>Loading B</span>
301+
<span>Loading C</span>
302+
</div>,
303+
);
304+
305+
await serverAct(() => C.resolve());
306+
assertLog(['C']);
307+
308+
expect(getVisibleChildren(container)).toEqual(
309+
<div>
310+
<span>Loading A</span>
311+
<span>Loading B</span>
312+
<span>C</span>
313+
</div>,
314+
);
315+
316+
await serverAct(() => B.resolve());
317+
assertLog(['B']);
318+
319+
expect(getVisibleChildren(container)).toEqual(
320+
<div>
321+
<span>A</span>
322+
<span>B</span>
323+
<span>C</span>
324+
</div>,
325+
);
326+
});
327+
});

0 commit comments

Comments
 (0)