Skip to content

Commit c38274e

Browse files
sebmarkbageAndyPengc12
authored andcommitted
[Fizz] Track postponed holes in the prerender pass (facebook#27317)
This is basically the implementation for the prerender pass. Instead of forking basically the whole implementation for prerender, I just add a conditional field on the request. If it's `null` it behaves like before. If it's non-`null` then instead of triggering client rendered boundaries it triggers those into a "postponed" state which is basically just a variant of "pending". It's supposed to be filled in later. It also builds up a serializable tree of which path can be followed to find the holes. This is basically a reverse `KeyPath` tree. It is unfortunate that this approach adds more code to the regular Fizz builds but in practice. It seems like this side is not going to add much code and we might instead just want to merge the builds so that it's smaller when you have `prerender` and `resume` in the same bundle - which I think will be common in practice. This just implements the prerender side, and not the resume side, which is why the tests have a TODO. That's in a follow up PR.
1 parent 3748316 commit c38274e

24 files changed

+588
-147
lines changed

packages/react-dom/npm/server.node.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@ exports.renderToStaticMarkup = l.renderToStaticMarkup;
1515
exports.renderToNodeStream = l.renderToNodeStream;
1616
exports.renderToStaticNodeStream = l.renderToStaticNodeStream;
1717
exports.renderToPipeableStream = s.renderToPipeableStream;
18-
if (s.resume) {
19-
exports.resume = s.resume;
18+
if (s.resumeToPipeableStream) {
19+
exports.resumeToPipeableStream = s.resumeToPipeableStream;
2020
}

packages/react-dom/server.node.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ export function renderToPipeableStream() {
4343
);
4444
}
4545

46-
export function resume() {
47-
return require('./src/server/react-dom-server.node').resume.apply(
46+
export function resumeToPipeableStream() {
47+
return require('./src/server/react-dom-server.node').resumeToPipeableStream.apply(
4848
this,
4949
arguments,
5050
);

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

Lines changed: 61 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
mergeOptions,
1515
stripExternalRuntimeInNodes,
1616
withLoadingReadyState,
17+
getVisibleChildren,
1718
} from '../test-utils/FizzTestUtils';
1819

1920
let JSDOM;
@@ -23,6 +24,7 @@ let React;
2324
let ReactDOM;
2425
let ReactDOMClient;
2526
let ReactDOMFizzServer;
27+
let ReactDOMFizzStatic;
2628
let Suspense;
2729
let SuspenseList;
2830
let useSyncExternalStore;
@@ -77,6 +79,9 @@ describe('ReactDOMFizzServer', () => {
7779
ReactDOM = require('react-dom');
7880
ReactDOMClient = require('react-dom/client');
7981
ReactDOMFizzServer = require('react-dom/server');
82+
if (__EXPERIMENTAL__) {
83+
ReactDOMFizzStatic = require('react-dom/static');
84+
}
8085
Stream = require('stream');
8186
Suspense = React.Suspense;
8287
use = React.use;
@@ -289,46 +294,6 @@ describe('ReactDOMFizzServer', () => {
289294
}, document);
290295
}
291296

292-
function getVisibleChildren(element) {
293-
const children = [];
294-
let node = element.firstChild;
295-
while (node) {
296-
if (node.nodeType === 1) {
297-
if (
298-
node.tagName !== 'SCRIPT' &&
299-
node.tagName !== 'script' &&
300-
node.tagName !== 'TEMPLATE' &&
301-
node.tagName !== 'template' &&
302-
!node.hasAttribute('hidden') &&
303-
!node.hasAttribute('aria-hidden')
304-
) {
305-
const props = {};
306-
const attributes = node.attributes;
307-
for (let i = 0; i < attributes.length; i++) {
308-
if (
309-
attributes[i].name === 'id' &&
310-
attributes[i].value.includes(':')
311-
) {
312-
// We assume this is a React added ID that's a non-visual implementation detail.
313-
continue;
314-
}
315-
props[attributes[i].name] = attributes[i].value;
316-
}
317-
props.children = getVisibleChildren(node);
318-
children.push(React.createElement(node.tagName.toLowerCase(), props));
319-
}
320-
} else if (node.nodeType === 3) {
321-
children.push(node.data);
322-
}
323-
node = node.nextSibling;
324-
}
325-
return children.length === 0
326-
? undefined
327-
: children.length === 1
328-
? children[0]
329-
: children;
330-
}
331-
332297
function resolveText(text) {
333298
const record = textCache.get(text);
334299
if (record === undefined) {
@@ -6227,4 +6192,60 @@ describe('ReactDOMFizzServer', () => {
62276192
);
62286193
},
62296194
);
6195+
6196+
// @gate enablePostpone
6197+
it('supports postponing in prerender and resuming later', async () => {
6198+
let prerendering = true;
6199+
function Postpone() {
6200+
if (prerendering) {
6201+
React.unstable_postpone();
6202+
}
6203+
return 'Hello';
6204+
}
6205+
6206+
function App() {
6207+
return (
6208+
<div>
6209+
<Suspense fallback="Loading...">
6210+
<Postpone />
6211+
</Suspense>
6212+
</div>
6213+
);
6214+
}
6215+
6216+
const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(<App />);
6217+
expect(prerendered.postponed).not.toBe(null);
6218+
6219+
prerendering = false;
6220+
6221+
const resumed = ReactDOMFizzServer.resumeToPipeableStream(
6222+
<App />,
6223+
prerendered.postponed,
6224+
);
6225+
6226+
// Create a separate stream so it doesn't close the writable. I.e. simple concat.
6227+
const preludeWritable = new Stream.PassThrough();
6228+
preludeWritable.setEncoding('utf8');
6229+
preludeWritable.on('data', chunk => {
6230+
writable.write(chunk);
6231+
});
6232+
6233+
await act(() => {
6234+
prerendered.prelude.pipe(preludeWritable);
6235+
});
6236+
6237+
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
6238+
6239+
const b = new Stream.PassThrough();
6240+
b.setEncoding('utf8');
6241+
b.on('data', chunk => {
6242+
writable.write(chunk);
6243+
});
6244+
6245+
await act(() => {
6246+
resumed.pipe(writable);
6247+
});
6248+
6249+
// TODO: expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
6250+
});
62306251
});

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

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,37 @@
99

1010
'use strict';
1111

12+
import {
13+
getVisibleChildren,
14+
insertNodesAndExecuteScripts,
15+
} from '../test-utils/FizzTestUtils';
16+
1217
// Polyfills for test environment
1318
global.ReadableStream =
1419
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
1520
global.TextEncoder = require('util').TextEncoder;
1621

1722
let React;
23+
let ReactDOMFizzServer;
1824
let ReactDOMFizzStatic;
1925
let Suspense;
26+
let container;
2027

2128
describe('ReactDOMFizzStaticBrowser', () => {
2229
beforeEach(() => {
2330
jest.resetModules();
2431
React = require('react');
32+
ReactDOMFizzServer = require('react-dom/server.browser');
2533
if (__EXPERIMENTAL__) {
2634
ReactDOMFizzStatic = require('react-dom/static.browser');
2735
}
2836
Suspense = React.Suspense;
37+
container = document.createElement('div');
38+
document.body.appendChild(container);
39+
});
40+
41+
afterEach(() => {
42+
document.body.removeChild(container);
2943
});
3044

3145
const theError = new Error('This is an error');
@@ -37,6 +51,36 @@ describe('ReactDOMFizzStaticBrowser', () => {
3751
throw theInfinitePromise;
3852
}
3953

54+
function concat(streamA, streamB) {
55+
const readerA = streamA.getReader();
56+
const readerB = streamB.getReader();
57+
return new ReadableStream({
58+
start(controller) {
59+
function readA() {
60+
readerA.read().then(({done, value}) => {
61+
if (done) {
62+
readB();
63+
return;
64+
}
65+
controller.enqueue(value);
66+
readA();
67+
});
68+
}
69+
function readB() {
70+
readerB.read().then(({done, value}) => {
71+
if (done) {
72+
controller.close();
73+
return;
74+
}
75+
controller.enqueue(value);
76+
readB();
77+
});
78+
}
79+
readA();
80+
},
81+
});
82+
}
83+
4084
async function readContent(stream) {
4185
const reader = stream.getReader();
4286
let content = '';
@@ -49,6 +93,21 @@ describe('ReactDOMFizzStaticBrowser', () => {
4993
}
5094
}
5195

96+
async function readIntoContainer(stream) {
97+
const reader = stream.getReader();
98+
let result = '';
99+
while (true) {
100+
const {done, value} = await reader.read();
101+
if (done) {
102+
break;
103+
}
104+
result += Buffer.from(value).toString('utf8');
105+
}
106+
const temp = document.createElement('div');
107+
temp.innerHTML = result;
108+
insertNodesAndExecuteScripts(temp, container, null);
109+
}
110+
52111
// @gate experimental
53112
it('should call prerender', async () => {
54113
const result = await ReactDOMFizzStatic.prerender(<div>hello world</div>);
@@ -394,4 +453,82 @@ describe('ReactDOMFizzStaticBrowser', () => {
394453

395454
expect(errors).toEqual(['uh oh', 'uh oh']);
396455
});
456+
457+
// @gate enablePostpone
458+
it('supports postponing in prerender and resuming later', async () => {
459+
let prerendering = true;
460+
function Postpone() {
461+
if (prerendering) {
462+
React.unstable_postpone();
463+
}
464+
return 'Hello';
465+
}
466+
467+
function App() {
468+
return (
469+
<div>
470+
<Suspense fallback="Loading...">
471+
<Postpone />
472+
</Suspense>
473+
</div>
474+
);
475+
}
476+
477+
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
478+
expect(prerendered.postponed).not.toBe(null);
479+
480+
prerendering = false;
481+
482+
const resumed = await ReactDOMFizzServer.resume(
483+
<App />,
484+
prerendered.postponed,
485+
);
486+
487+
await readIntoContainer(prerendered.prelude);
488+
489+
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
490+
491+
await readIntoContainer(resumed);
492+
493+
// TODO: expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
494+
});
495+
496+
// @gate enablePostpone
497+
it('only emits end tags once when resuming', async () => {
498+
let prerendering = true;
499+
function Postpone() {
500+
if (prerendering) {
501+
React.unstable_postpone();
502+
}
503+
return 'Hello';
504+
}
505+
506+
function App() {
507+
return (
508+
<html>
509+
<body>
510+
<Suspense fallback="Loading...">
511+
<Postpone />
512+
</Suspense>
513+
</body>
514+
</html>
515+
);
516+
}
517+
518+
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
519+
expect(prerendered.postponed).not.toBe(null);
520+
521+
prerendering = false;
522+
523+
const content = await ReactDOMFizzServer.resume(
524+
<App />,
525+
prerendered.postponed,
526+
);
527+
528+
const html = await readContent(concat(prerendered.prelude, content));
529+
const htmlEndTags = /<\/html\s*>/gi;
530+
const bodyEndTags = /<\/body\s*>/gi;
531+
expect(Array.from(html.matchAll(htmlEndTags)).length).toBe(1);
532+
expect(Array.from(html.matchAll(bodyEndTags)).length).toBe(1);
533+
});
397534
});

packages/react-dom/src/server/ReactDOMFizzServerBrowser.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import ReactVersion from 'shared/ReactVersion';
1616

1717
import {
1818
createRequest,
19-
startWork,
19+
startRender,
2020
startFlowing,
2121
abort,
2222
} from 'react-server/src/ReactFizzServer';
@@ -129,7 +129,7 @@ function renderToReadableStream(
129129
signal.addEventListener('abort', listener);
130130
}
131131
}
132-
startWork(request);
132+
startRender(request);
133133
});
134134
}
135135

@@ -200,7 +200,7 @@ function resume(
200200
signal.addEventListener('abort', listener);
201201
}
202202
}
203-
startWork(request);
203+
startRender(request);
204204
});
205205
}
206206

packages/react-dom/src/server/ReactDOMFizzServerBun.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import ReactVersion from 'shared/ReactVersion';
1515

1616
import {
1717
createRequest,
18-
startWork,
18+
startRender,
1919
startFlowing,
2020
abort,
2121
} from 'react-server/src/ReactFizzServer';
@@ -121,7 +121,7 @@ function renderToReadableStream(
121121
signal.addEventListener('abort', listener);
122122
}
123123
}
124-
startWork(request);
124+
startRender(request);
125125
});
126126
}
127127

0 commit comments

Comments
 (0)