mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
143d3e1b89
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.
511 lines
13 KiB
JavaScript
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);
|
|
});
|
|
});
|