Files
react/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js
Sebastian Markbåge 143d3e1b89 [Fizz] Emit link rel="expect" to block render before the shell has fully loaded (#33016)
The semantics of React is that anything outside of Suspense boundaries
in a transition doesn't display until it has fully unsuspended. With SSR
streaming the intention is to preserve that.

We explicitly don't want to support the mode of document streaming
normally supported by the browser where it can paint content as tags
stream in since that leads to content popping in and thrashing in
unpredictable ways. This should instead be modeled explictly by nested
Suspense boundaries or something like SuspenseList.

After the first shell any nested Suspense boundaries are only revealed,
by script, once they're fully streamed in to the next boundary. So this
is already the case there. However, for the initial shell we have been
at the mercy of browser heuristics for how long it decides to stream
before the first paint.

Chromium now has [an API explicitly for this use
case](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#stabilizing_page_state_to_make_cross-document_transitions_consistent)
that lets us model the semantics that we want. This is always important
but especially so with MPA View Transitions.

After this a simple document looks like this:

```html
<!DOCTYPE html>
<html>
  <head>
     <link rel="expect" href="#«R»" blocking="render"/>
  </head>
  <body>
    <p>hello world</p>
    <script src="bootstrap.js" id="«R»" async=""></script>
    ...
  </body>
</html>
```

The `rel="expect"` tag indicates that we want to wait to paint until we
have streamed far enough to be able to paint the id `"«R»"` which
indicates the shell.

Ideally this `id` would be assigned to the root most HTML element in the
body. However, this is tricky in our implementation because there can be
multiple and we can render them out of order.

So instead, we assign the id to the first bootstrap script if there is
one since these are always added to the end of the shell. If there isn't
a bootstrap script then we emit an empty `<template
id="«R»"></template>` instead as a marker.

Since we currently put as much as possible in the shell if it's loaded
by the time we render, this can have some negative effects for very
large documents. We should instead apply the heuristic where very large
Suspense boundaries get outlined outside the shell even if they're
immediately available. This means that even prerenders can end up with
script tags.

We only emit the `rel="expect"` if you're rendering a whole document.
I.e. if you rendered either a `<html>` or `<head>` tag. If you're
rendering a partial document, then we don't really know where the
streaming parts are anyway and can't provide such guarantees. This does
apply whether you're streaming or not because we still want to block
rendering until the end, but in practice any serialized state that needs
hydrate should still be embedded after the completion id.
2025-04-25 11:52:28 -04:00

511 lines
13 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
*/
'use strict';
let JSDOM;
let Stream;
let React;
let ReactDOM;
let ReactDOMClient;
let ReactDOMFizzStatic;
let Suspense;
let textCache;
let document;
let writable;
let container;
let buffer = '';
let hasErrored = false;
let fatalError = undefined;
describe('ReactDOMFizzStatic', () => {
beforeEach(() => {
jest.resetModules();
JSDOM = require('jsdom').JSDOM;
React = require('react');
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
ReactDOMFizzStatic = require('react-dom/static');
Stream = require('stream');
Suspense = React.Suspense;
textCache = new Map();
// Test Environment
const jsdom = new JSDOM(
'<!DOCTYPE html><html><head></head><body><div id="container">',
{
runScripts: 'dangerously',
},
);
document = jsdom.window.document;
container = document.getElementById('container');
buffer = '';
hasErrored = false;
writable = new Stream.PassThrough();
writable.setEncoding('utf8');
writable.on('data', chunk => {
buffer += chunk;
});
writable.on('error', error => {
hasErrored = true;
fatalError = error;
});
});
async function act(callback) {
await callback();
// Await one turn around the event loop.
// This assumes that we'll flush everything we have so far.
await new Promise(resolve => {
setImmediate(resolve);
});
if (hasErrored) {
throw fatalError;
}
// JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
// We also want to execute any scripts that are embedded.
// We assume that we have now received a proper fragment of HTML.
const bufferedContent = buffer;
buffer = '';
const fakeBody = document.createElement('body');
fakeBody.innerHTML = bufferedContent;
while (fakeBody.firstChild) {
const node = fakeBody.firstChild;
if (node.nodeName === 'SCRIPT') {
const script = document.createElement('script');
script.textContent = node.textContent;
for (let i = 0; i < node.attributes.length; i++) {
const attribute = node.attributes[i];
script.setAttribute(attribute.name, attribute.value);
}
fakeBody.removeChild(node);
container.appendChild(script);
} else {
container.appendChild(node);
}
}
}
function getVisibleChildren(element) {
const children = [];
let node = element.firstChild;
while (node) {
if (node.nodeType === 1) {
if (
(node.tagName !== 'SCRIPT' || node.hasAttribute('type')) &&
node.tagName !== 'TEMPLATE' &&
node.tagName !== 'template' &&
!node.hasAttribute('hidden') &&
!node.hasAttribute('aria-hidden') &&
// Ignore the render blocking expect
(node.getAttribute('rel') !== 'expect' ||
node.getAttribute('blocking') !== 'render')
) {
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) {
const newRecord = {
status: 'resolved',
value: text,
};
textCache.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'resolved';
record.value = text;
thenable.pings.forEach(t => t());
}
}
/*
function rejectText(text, error) {
const record = textCache.get(text);
if (record === undefined) {
const newRecord = {
status: 'rejected',
value: error,
};
textCache.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'rejected';
record.value = error;
thenable.pings.forEach(t => t());
}
}
*/
function readText(text) {
const record = textCache.get(text);
if (record !== undefined) {
switch (record.status) {
case 'pending':
throw record.value;
case 'rejected':
throw record.value;
case 'resolved':
return record.value;
}
} else {
const thenable = {
pings: [],
then(resolve) {
if (newRecord.status === 'pending') {
thenable.pings.push(resolve);
} else {
Promise.resolve().then(() => resolve(newRecord.value));
}
},
};
const newRecord = {
status: 'pending',
value: thenable,
};
textCache.set(text, newRecord);
throw thenable;
}
}
function Text({text}) {
return text;
}
function AsyncText({text}) {
return readText(text);
}
it('should render a fully static document, send it and then hydrate it', async () => {
function App() {
return (
<div>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Hello" />
</Suspense>
</div>
);
}
const promise = ReactDOMFizzStatic.prerenderToNodeStream(<App />);
resolveText('Hello');
const result = await promise;
expect(result.postponed).toBe(
gate(flags => flags.enableHalt || flags.enablePostpone)
? null
: undefined,
);
await act(async () => {
result.prelude.pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
await act(async () => {
ReactDOMClient.hydrateRoot(container, <App />);
});
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
});
it('should support importMap option', async () => {
const importMap = {
foo: 'path/to/foo.js',
};
const result = await ReactDOMFizzStatic.prerenderToNodeStream(
<html>
<body>hello world</body>
</html>,
{importMap},
);
await act(async () => {
result.prelude.pipe(writable);
});
expect(getVisibleChildren(container)).toEqual([
<script type="importmap">{JSON.stringify(importMap)}</script>,
'hello world',
]);
});
it('supports onHeaders', async () => {
let headers;
function onHeaders(x) {
headers = x;
}
function App() {
ReactDOM.preload('image', {as: 'image', fetchPriority: 'high'});
ReactDOM.preload('font', {as: 'font'});
return (
<html>
<body>hello</body>
</html>
);
}
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
onHeaders,
});
expect(headers).toEqual({
Link: `
<font>; rel=preload; as="font"; crossorigin="",
<image>; rel=preload; as="image"; fetchpriority="high"
`
.replaceAll('\n', '')
.trim(),
});
await act(async () => {
result.prelude.pipe(writable);
});
expect(getVisibleChildren(container)).toEqual('hello');
});
// @gate enablePostpone
it('includes stylesheet preloads in onHeaders when postponing in the Shell', async () => {
let headers;
function onHeaders(x) {
headers = x;
}
function App() {
ReactDOM.preload('image', {as: 'image', fetchPriority: 'high'});
ReactDOM.preinit('style', {as: 'style'});
React.unstable_postpone();
return (
<html>
<body>hello</body>
</html>
);
}
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
onHeaders,
});
expect(headers).toEqual({
Link: `
<image>; rel=preload; as="image"; fetchpriority="high",
<style>; rel=preload; as="style"
`
.replaceAll('\n', '')
.trim(),
});
await act(async () => {
result.prelude.pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(undefined);
});
it('will prerender Suspense fallbacks before children', async () => {
const values = [];
function Indirection({children}) {
values.push(children);
return children;
}
function App() {
return (
<div>
<Suspense
fallback={
<div>
<Indirection>outer loading...</Indirection>
</div>
}>
<Suspense
fallback={
<div>
<Indirection>first inner loading...</Indirection>
</div>
}>
<div>
<Indirection>hello world</Indirection>
</div>
</Suspense>
<Suspense
fallback={
<div>
<Indirection>second inner loading...</Indirection>
</div>
}>
<div>
<Indirection>goodbye world</Indirection>
</div>
</Suspense>
</Suspense>
</div>
);
}
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />);
expect(values).toEqual([
'outer loading...',
'first inner loading...',
'second inner loading...',
'hello world',
'goodbye world',
]);
await act(async () => {
result.prelude.pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div>hello world</div>
<div>goodbye world</div>
</div>,
);
});
// @gate enablePostpone
it('does not fatally error when aborting with a postpone during a prerender', async () => {
let postponedValue;
try {
React.unstable_postpone('aborting with postpone');
} catch (e) {
postponedValue = e;
}
const controller = new AbortController();
const infinitePromise = new Promise(() => {});
function App() {
React.use(infinitePromise);
return <div>aborted</div>;
}
const pendingResult = ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
signal: controller.signal,
});
pendingResult.catch(() => {});
await Promise.resolve();
controller.abort(postponedValue);
const result = await pendingResult;
await act(async () => {
result.prelude.pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(undefined);
});
// @gate enablePostpone
it('does not fatally error when aborting with a postpone during a prerender from within', async () => {
let postponedValue;
try {
React.unstable_postpone('aborting with postpone');
} catch (e) {
postponedValue = e;
}
const controller = new AbortController();
function App() {
controller.abort(postponedValue);
return <div>aborted</div>;
}
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
signal: controller.signal,
});
await act(async () => {
result.prelude.pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(undefined);
});
// @gate enableHalt
it('will halt a prerender when aborting with an error during a render', async () => {
const controller = new AbortController();
function App() {
controller.abort('sync');
return <div>hello world</div>;
}
const errors = [];
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
signal: controller.signal,
onError(error) {
errors.push(error);
},
});
await act(async () => {
result.prelude.pipe(writable);
});
expect(errors).toEqual(['sync']);
expect(getVisibleChildren(container)).toEqual(undefined);
});
// @gate enableHalt
it('will halt a prerender when aborting with an error in a microtask', async () => {
const errors = [];
const controller = new AbortController();
function App() {
React.use(
new Promise(() => {
Promise.resolve().then(() => {
controller.abort('async');
});
}),
);
return <div>hello world</div>;
}
errors.length = 0;
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
signal: controller.signal,
onError(error) {
errors.push(error);
},
});
await act(async () => {
result.prelude.pipe(writable);
});
expect(errors).toEqual(['async']);
expect(getVisibleChildren(container)).toEqual(undefined);
});
});