Skip to content

Commit

Permalink
[Fizz] Track postponed holes in the prerender pass (facebook#27317)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sebmarkbage authored and AndyPengc12 committed Apr 15, 2024
1 parent 3748316 commit c38274e
Show file tree
Hide file tree
Showing 24 changed files with 588 additions and 147 deletions.
4 changes: 2 additions & 2 deletions packages/react-dom/npm/server.node.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ exports.renderToStaticMarkup = l.renderToStaticMarkup;
exports.renderToNodeStream = l.renderToNodeStream;
exports.renderToStaticNodeStream = l.renderToStaticNodeStream;
exports.renderToPipeableStream = s.renderToPipeableStream;
if (s.resume) {
exports.resume = s.resume;
if (s.resumeToPipeableStream) {
exports.resumeToPipeableStream = s.resumeToPipeableStream;
}
4 changes: 2 additions & 2 deletions packages/react-dom/server.node.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ export function renderToPipeableStream() {
);
}

export function resume() {
return require('./src/server/react-dom-server.node').resume.apply(
export function resumeToPipeableStream() {
return require('./src/server/react-dom-server.node').resumeToPipeableStream.apply(
this,
arguments,
);
Expand Down
101 changes: 61 additions & 40 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
mergeOptions,
stripExternalRuntimeInNodes,
withLoadingReadyState,
getVisibleChildren,
} from '../test-utils/FizzTestUtils';

let JSDOM;
Expand All @@ -23,6 +24,7 @@ let React;
let ReactDOM;
let ReactDOMClient;
let ReactDOMFizzServer;
let ReactDOMFizzStatic;
let Suspense;
let SuspenseList;
let useSyncExternalStore;
Expand Down Expand Up @@ -77,6 +79,9 @@ describe('ReactDOMFizzServer', () => {
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
ReactDOMFizzServer = require('react-dom/server');
if (__EXPERIMENTAL__) {
ReactDOMFizzStatic = require('react-dom/static');
}
Stream = require('stream');
Suspense = React.Suspense;
use = React.use;
Expand Down Expand Up @@ -289,46 +294,6 @@ describe('ReactDOMFizzServer', () => {
}, document);
}

function getVisibleChildren(element) {
const children = [];
let node = element.firstChild;
while (node) {
if (node.nodeType === 1) {
if (
node.tagName !== 'SCRIPT' &&
node.tagName !== 'script' &&
node.tagName !== 'TEMPLATE' &&
node.tagName !== 'template' &&
!node.hasAttribute('hidden') &&
!node.hasAttribute('aria-hidden')
) {
const props = {};
const attributes = node.attributes;
for (let i = 0; i < attributes.length; i++) {
if (
attributes[i].name === 'id' &&
attributes[i].value.includes(':')
) {
// We assume this is a React added ID that's a non-visual implementation detail.
continue;
}
props[attributes[i].name] = attributes[i].value;
}
props.children = getVisibleChildren(node);
children.push(React.createElement(node.tagName.toLowerCase(), props));
}
} else if (node.nodeType === 3) {
children.push(node.data);
}
node = node.nextSibling;
}
return children.length === 0
? undefined
: children.length === 1
? children[0]
: children;
}

function resolveText(text) {
const record = textCache.get(text);
if (record === undefined) {
Expand Down Expand Up @@ -6227,4 +6192,60 @@ describe('ReactDOMFizzServer', () => {
);
},
);

// @gate enablePostpone
it('supports postponing in prerender and resuming later', async () => {
let prerendering = true;
function Postpone() {
if (prerendering) {
React.unstable_postpone();
}
return 'Hello';
}

function App() {
return (
<div>
<Suspense fallback="Loading...">
<Postpone />
</Suspense>
</div>
);
}

const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(<App />);
expect(prerendered.postponed).not.toBe(null);

prerendering = false;

const resumed = ReactDOMFizzServer.resumeToPipeableStream(
<App />,
prerendered.postponed,
);

// Create a separate stream so it doesn't close the writable. I.e. simple concat.
const preludeWritable = new Stream.PassThrough();
preludeWritable.setEncoding('utf8');
preludeWritable.on('data', chunk => {
writable.write(chunk);
});

await act(() => {
prerendered.prelude.pipe(preludeWritable);
});

expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);

const b = new Stream.PassThrough();
b.setEncoding('utf8');
b.on('data', chunk => {
writable.write(chunk);
});

await act(() => {
resumed.pipe(writable);
});

// TODO: expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
});
});
137 changes: 137 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,37 @@

'use strict';

import {
getVisibleChildren,
insertNodesAndExecuteScripts,
} from '../test-utils/FizzTestUtils';

// Polyfills for test environment
global.ReadableStream =
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
global.TextEncoder = require('util').TextEncoder;

let React;
let ReactDOMFizzServer;
let ReactDOMFizzStatic;
let Suspense;
let container;

describe('ReactDOMFizzStaticBrowser', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactDOMFizzServer = require('react-dom/server.browser');
if (__EXPERIMENTAL__) {
ReactDOMFizzStatic = require('react-dom/static.browser');
}
Suspense = React.Suspense;
container = document.createElement('div');
document.body.appendChild(container);
});

afterEach(() => {
document.body.removeChild(container);
});

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

function concat(streamA, streamB) {
const readerA = streamA.getReader();
const readerB = streamB.getReader();
return new ReadableStream({
start(controller) {
function readA() {
readerA.read().then(({done, value}) => {
if (done) {
readB();
return;
}
controller.enqueue(value);
readA();
});
}
function readB() {
readerB.read().then(({done, value}) => {
if (done) {
controller.close();
return;
}
controller.enqueue(value);
readB();
});
}
readA();
},
});
}

async function readContent(stream) {
const reader = stream.getReader();
let content = '';
Expand All @@ -49,6 +93,21 @@ describe('ReactDOMFizzStaticBrowser', () => {
}
}

async function readIntoContainer(stream) {
const reader = stream.getReader();
let result = '';
while (true) {
const {done, value} = await reader.read();
if (done) {
break;
}
result += Buffer.from(value).toString('utf8');
}
const temp = document.createElement('div');
temp.innerHTML = result;
insertNodesAndExecuteScripts(temp, container, null);
}

// @gate experimental
it('should call prerender', async () => {
const result = await ReactDOMFizzStatic.prerender(<div>hello world</div>);
Expand Down Expand Up @@ -394,4 +453,82 @@ describe('ReactDOMFizzStaticBrowser', () => {

expect(errors).toEqual(['uh oh', 'uh oh']);
});

// @gate enablePostpone
it('supports postponing in prerender and resuming later', async () => {
let prerendering = true;
function Postpone() {
if (prerendering) {
React.unstable_postpone();
}
return 'Hello';
}

function App() {
return (
<div>
<Suspense fallback="Loading...">
<Postpone />
</Suspense>
</div>
);
}

const prerendered = await ReactDOMFizzStatic.prerender(<App />);
expect(prerendered.postponed).not.toBe(null);

prerendering = false;

const resumed = await ReactDOMFizzServer.resume(
<App />,
prerendered.postponed,
);

await readIntoContainer(prerendered.prelude);

expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);

await readIntoContainer(resumed);

// TODO: expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
});

// @gate enablePostpone
it('only emits end tags once when resuming', async () => {
let prerendering = true;
function Postpone() {
if (prerendering) {
React.unstable_postpone();
}
return 'Hello';
}

function App() {
return (
<html>
<body>
<Suspense fallback="Loading...">
<Postpone />
</Suspense>
</body>
</html>
);
}

const prerendered = await ReactDOMFizzStatic.prerender(<App />);
expect(prerendered.postponed).not.toBe(null);

prerendering = false;

const content = await ReactDOMFizzServer.resume(
<App />,
prerendered.postponed,
);

const html = await readContent(concat(prerendered.prelude, content));
const htmlEndTags = /<\/html\s*>/gi;
const bodyEndTags = /<\/body\s*>/gi;
expect(Array.from(html.matchAll(htmlEndTags)).length).toBe(1);
expect(Array.from(html.matchAll(bodyEndTags)).length).toBe(1);
});
});
6 changes: 3 additions & 3 deletions packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import ReactVersion from 'shared/ReactVersion';

import {
createRequest,
startWork,
startRender,
startFlowing,
abort,
} from 'react-server/src/ReactFizzServer';
Expand Down Expand Up @@ -129,7 +129,7 @@ function renderToReadableStream(
signal.addEventListener('abort', listener);
}
}
startWork(request);
startRender(request);
});
}

Expand Down Expand Up @@ -200,7 +200,7 @@ function resume(
signal.addEventListener('abort', listener);
}
}
startWork(request);
startRender(request);
});
}

Expand Down
4 changes: 2 additions & 2 deletions packages/react-dom/src/server/ReactDOMFizzServerBun.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import ReactVersion from 'shared/ReactVersion';

import {
createRequest,
startWork,
startRender,
startFlowing,
abort,
} from 'react-server/src/ReactFizzServer';
Expand Down Expand Up @@ -121,7 +121,7 @@ function renderToReadableStream(
signal.addEventListener('abort', listener);
}
}
startWork(request);
startRender(request);
});
}

Expand Down
Loading

0 comments on commit c38274e

Please sign in to comment.