/**
* 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';
import {
insertNodesAndExecuteScripts,
mergeOptions,
stripExternalRuntimeInNodes,
getVisibleChildren,
} from '../test-utils/FizzTestUtils';
let JSDOM;
let Stream;
let Scheduler;
let React;
let ReactDOM;
let ReactDOMClient;
let ReactDOMFizzServer;
let ReactDOMFizzStatic;
let Suspense;
let SuspenseList;
let assertConsoleErrorDev;
let useSyncExternalStore;
let useSyncExternalStoreWithSelector;
let use;
let useActionState;
let PropTypes;
let textCache;
let writable;
let CSPnonce = null;
let container;
let buffer = '';
let hasErrored = false;
let fatalError = undefined;
let renderOptions;
let waitFor;
let waitForAll;
let assertLog;
let waitForPaint;
let clientAct;
let streamingContainer;
function normalizeError(msg) {
// Take the first sentence to make it easier to assert on.
const idx = msg.indexOf('.');
if (idx > -1) {
return msg.slice(0, idx + 1);
}
return msg;
}
describe('ReactDOMFizzServer', () => {
beforeEach(() => {
jest.resetModules();
JSDOM = require('jsdom').JSDOM;
const jsdom = new JSDOM(
'
',
{
runScripts: 'dangerously',
},
);
// We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else
Object.defineProperty(jsdom.window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: query === 'all' || query === '',
media: query,
})),
});
streamingContainer = null;
global.window = jsdom.window;
global.document = global.window.document;
global.navigator = global.window.navigator;
global.Node = global.window.Node;
global.addEventListener = global.window.addEventListener;
global.MutationObserver = global.window.MutationObserver;
// The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
setTimeout(cb);
container = document.getElementById('container');
CSPnonce = null;
Scheduler = require('scheduler');
React = require('react');
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
ReactDOMFizzServer = require('react-dom/server');
ReactDOMFizzStatic = require('react-dom/static');
Stream = require('stream');
Suspense = React.Suspense;
use = React.use;
if (gate(flags => flags.enableSuspenseList)) {
SuspenseList = React.unstable_SuspenseList;
}
PropTypes = require('prop-types');
if (__VARIANT__) {
const originalConsoleError = console.error;
console.error = (error, ...args) => {
if (
typeof error !== 'string' ||
error.indexOf('ReactDOM.useFormState has been renamed') === -1
) {
originalConsoleError(error, ...args);
}
};
// Remove after API is deleted.
useActionState = ReactDOM.useFormState;
} else {
useActionState = React.useActionState;
}
({
assertConsoleErrorDev,
assertLog,
act: clientAct,
waitFor,
waitForAll,
waitForPaint,
} = require('internal-test-utils'));
if (gate(flags => flags.source)) {
// The `with-selector` module composes the main `use-sync-external-store`
// entrypoint. In the compiled artifacts, this is resolved to the `shim`
// implementation by our build config, but when running the tests against
// the source files, we need to tell Jest how to resolve it. Because this
// is a source module, this mock has no affect on the build tests.
jest.mock('use-sync-external-store/src/useSyncExternalStore', () =>
jest.requireActual('react'),
);
}
useSyncExternalStore = React.useSyncExternalStore;
useSyncExternalStoreWithSelector =
require('use-sync-external-store/with-selector').useSyncExternalStoreWithSelector;
textCache = new Map();
buffer = '';
hasErrored = false;
writable = new Stream.PassThrough();
writable.setEncoding('utf8');
writable.on('data', chunk => {
buffer += chunk;
});
writable.on('error', error => {
hasErrored = true;
fatalError = error;
});
renderOptions = {};
if (gate(flags => flags.shouldUseFizzExternalRuntime)) {
renderOptions.unstable_externalRuntimeSrc =
'react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js';
}
});
function expectErrors(errorsArr, toBeDevArr, toBeProdArr) {
const mappedErrows = errorsArr.map(({error, errorInfo}) => {
const stack = errorInfo && errorInfo.componentStack;
const digest = error.digest;
if (stack) {
return [error.message, digest, normalizeCodeLocInfo(stack)];
} else if (digest) {
return [error.message, digest];
}
return error.message;
});
if (__DEV__) {
expect(mappedErrows).toEqual(toBeDevArr);
} else {
expect(mappedErrows).toEqual(toBeProdArr);
}
}
function componentStack(components) {
return components
.map(component => `\n in ${component} (at **)`)
.join('');
}
const bodyStartMatch = /| .*?>)/;
const headStartMatch = /| .*?>)/;
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.
let bufferedContent = buffer;
buffer = '';
if (!bufferedContent) {
jest.runAllTimers();
return;
}
const bodyMatch = bufferedContent.match(bodyStartMatch);
const headMatch = bufferedContent.match(headStartMatch);
if (streamingContainer === null) {
// This is the first streamed content. We decide here where to insert it. If we get , , or
// we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
// container. This is not really production behavior because you can't correctly stream into a deep div effectively
// but it's pragmatic for tests.
if (
bufferedContent.startsWith('') ||
bufferedContent.startsWith('') ||
bufferedContent.startsWith('') ||
bufferedContent.startsWith(' without a which is almost certainly a bug in React',
);
}
if (bufferedContent.startsWith('')) {
// we can just use the whole document
const tempDom = new JSDOM(bufferedContent);
// Wipe existing head and body content
document.head.innerHTML = '';
document.body.innerHTML = '';
// Copy the attributes over
const tempHtmlNode = tempDom.window.document.documentElement;
for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
const attr = tempHtmlNode.attributes[i];
document.documentElement.setAttribute(attr.name, attr.value);
}
if (headMatch) {
// We parsed a head open tag. we need to copy head attributes and insert future
// content into
streamingContainer = document.head;
const tempHeadNode = tempDom.window.document.head;
for (let i = 0; i < tempHeadNode.attributes.length; i++) {
const attr = tempHeadNode.attributes[i];
document.head.setAttribute(attr.name, attr.value);
}
const source = document.createElement('head');
source.innerHTML = tempHeadNode.innerHTML;
await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
}
if (bodyMatch) {
// We parsed a body open tag. we need to copy head attributes and insert future
// content into
streamingContainer = document.body;
const tempBodyNode = tempDom.window.document.body;
for (let i = 0; i < tempBodyNode.attributes.length; i++) {
const attr = tempBodyNode.attributes[i];
document.body.setAttribute(attr.name, attr.value);
}
const source = document.createElement('body');
source.innerHTML = tempBodyNode.innerHTML;
await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
}
if (!headMatch && !bodyMatch) {
throw new Error('expected or after ');
}
} else {
// we assume we are streaming into the default container'
streamingContainer = container;
const div = document.createElement('div');
div.innerHTML = bufferedContent;
await insertNodesAndExecuteScripts(div, container, CSPnonce);
}
} else if (streamingContainer === document.head) {
bufferedContent = '' + bufferedContent;
const tempDom = new JSDOM(bufferedContent);
const tempHeadNode = tempDom.window.document.head;
const source = document.createElement('head');
source.innerHTML = tempHeadNode.innerHTML;
await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
if (bodyMatch) {
streamingContainer = document.body;
const tempBodyNode = tempDom.window.document.body;
for (let i = 0; i < tempBodyNode.attributes.length; i++) {
const attr = tempBodyNode.attributes[i];
document.body.setAttribute(attr.name, attr.value);
}
const bodySource = document.createElement('body');
bodySource.innerHTML = tempBodyNode.innerHTML;
await insertNodesAndExecuteScripts(bodySource, document.body, CSPnonce);
}
} else {
const div = document.createElement('div');
div.innerHTML = bufferedContent;
await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
}
// Let throttled boundaries reveal
jest.runAllTimers();
}
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);
}
function AsyncTextWrapped({as, text}) {
const As = as;
return {readText(text)};
}
function renderToPipeableStream(jsx, options) {
// Merge options with renderOptions, which may contain featureFlag specific behavior
return ReactDOMFizzServer.renderToPipeableStream(
jsx,
mergeOptions(options, renderOptions),
);
}
// @gate enableBrowserAPI
it('can opt a component into browser-only rendering', async () => {
let resolveBrowserText;
const browserText = new Promise(resolve => {
resolveBrowserText = resolve;
});
let browserReason;
const initializeReason = jest.fn(() => {
browserReason = Object.freeze(
new Error('Only render this content in a browser'),
);
return browserReason;
});
const browserOnly = ReactDOM.browser(initializeReason);
function BrowserOnly() {
use(browserOnly);
const text = use(browserText);
Scheduler.log(text);
return {text};
}
function App() {
return (
,
]);
// check that there are 6 scripts with a matching nonce:
// The runtime script or initial paint time, an inline bootstrap script, two bootstrap scripts and two bootstrap modules
expect(
Array.from(container.getElementsByTagName('script')).filter(
node => node.getAttribute('nonce') === CSPnonce,
).length,
).toEqual(6);
await act(() => {
resolve({default: Text});
});
expect(getVisibleChildren(container)).toEqual([
,
,
,
,
);
}
let bootstrapped = false;
const errors = [];
window.__INIT__ = function () {
bootstrapped = true;
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error, errorInfo) {
errors.push({error, errorInfo});
},
});
};
const theError = new Error('Test');
const loggedErrors = [];
function onError(x, errorInfo) {
loggedErrors.push(x);
return 'Hash of (' + x.message + ')';
}
const expectedDigest = onError(theError);
loggedErrors.length = 0;
await act(() => {
const {pipe} = renderToPipeableStream(, {
bootstrapScriptContent: '__INIT__();',
onError,
});
pipe(writable);
});
expect(loggedErrors).toEqual([]);
expect(bootstrapped).toBe(true);
await waitForAll([]);
// We're still loading because we're waiting for the server to stream more content.
expect(getVisibleChildren(container)).toEqual(
Loading...
);
expect(loggedErrors).toEqual([]);
await act(() => {
rejectComponent(theError);
});
expect(loggedErrors).toEqual([theError]);
// We haven't ran the client hydration yet.
expect(getVisibleChildren(container)).toEqual(
Loading...
);
// Now we can client render it instead.
await waitForAll([]);
expectErrors(
errors,
[
[
'Switched to client rendering because the server rendering errored:\n\n' +
theError.message,
expectedDigest,
componentStack(['Lazy', 'Wrapper', 'Suspense', 'div', 'App']),
],
],
[
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
expectedDigest,
],
],
);
// The client rendered HTML is now in place.
expect(getVisibleChildren(container)).toEqual(
Hello
);
expect(loggedErrors).toEqual([theError]);
});
it('should have special stacks if Suspense fallback', async () => {
const infinitePromise = new Promise(() => {});
const InfiniteComponent = React.lazy(() => {
return infinitePromise;
});
function Throw({text}) {
throw new Error(text);
}
function App() {
return (
);
// Because there is no content inside the Suspense boundary that could've
// been written, we expect to not see any additional partial data flushed
// yet.
expect(
stripExternalRuntimeInNodes(
container.childNodes,
renderOptions.unstable_externalRuntimeSrc,
).length,
).toBe(gate(flags => flags.shouldUseFizzExternalRuntime) ? 1 : 2);
await act(() => {
resolveElement({default: });
});
expect(getVisibleChildren(container)).toEqual(
Hello
);
});
it('should client render a boundary if a lazy element rejects', async () => {
let rejectElement;
const element = ;
const lazyElement = React.lazy(() => {
return new Promise((resolve, reject) => {
rejectElement = reject;
});
});
const theError = new Error('Test');
const loggedErrors = [];
function onError(x, errorInfo) {
loggedErrors.push(x);
return 'hash of (' + x.message + ')';
}
const expectedDigest = onError(theError);
loggedErrors.length = 0;
function App({isClient}) {
return (
}>
{isClient ? element : lazyElement}
);
}
await act(() => {
const {pipe} = renderToPipeableStream(, {
onError,
});
pipe(writable);
});
expect(loggedErrors).toEqual([]);
const errors = [];
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error, errorInfo) {
errors.push({error, errorInfo});
},
});
await waitForAll([]);
// We're still loading because we're waiting for the server to stream more content.
expect(getVisibleChildren(container)).toEqual(
Loading...
);
expect(loggedErrors).toEqual([]);
await act(() => {
rejectElement(theError);
});
expect(loggedErrors).toEqual([theError]);
// We haven't ran the client hydration yet.
expect(getVisibleChildren(container)).toEqual(
Loading...
);
// Now we can client render it instead.
await waitForAll([]);
expectErrors(
errors,
[
[
'Switched to client rendering because the server rendering errored:\n\n' +
theError.message,
expectedDigest,
componentStack(['Suspense', 'div', 'App']),
],
],
[
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
expectedDigest,
],
],
);
// The client rendered HTML is now in place.
// expect(getVisibleChildren(container)).toEqual(
Hello
);
expect(loggedErrors).toEqual([theError]);
});
it('Errors in boundaries should be sent to the client and reported on client render - Error before flushing', async () => {
function Indirection({level, children}) {
if (level > 0) {
return {children};
}
return children;
}
const theError = new Error('uh oh');
function Erroring({isClient}) {
if (isClient) {
return 'Hello World';
}
throw theError;
}
function App({isClient}) {
return (
);
expectErrors(
errors,
[
[
'Switched to client rendering because the server rendering errored:\n\n' +
theError.message,
expectedDigest,
componentStack([
'Erroring',
'Indirection',
'Indirection',
'Indirection',
'Suspense',
'div',
'App',
]),
],
],
[
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
expectedDigest,
],
],
);
});
it('Errors in boundaries should be sent to the client and reported on client render - Error after flushing', async () => {
let rejectComponent;
const LazyComponent = React.lazy(() => {
return new Promise((resolve, reject) => {
rejectComponent = reject;
});
});
function App({isClient}) {
return (
);
// Now we can client render it instead.
await waitForAll([]);
expectErrors(
errors,
[
[
'Switched to client rendering because the server rendering errored:\n\n' +
theError.message,
expectedDigest,
componentStack(['Lazy', 'Suspense', 'div', 'App']),
],
],
[
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
expectedDigest,
],
],
);
// The client rendered HTML is now in place.
expect(getVisibleChildren(container)).toEqual(
);
});
it('waits for pending content to come in from the server and then hydrates it', async () => {
const ref = React.createRef();
function App() {
return (
);
}
let bootstrapped = false;
window.__INIT__ = function () {
bootstrapped = true;
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, );
};
await act(() => {
const {pipe} = renderToPipeableStream(, {
bootstrapScriptContent: '__INIT__();',
});
pipe(writable);
});
// We're still showing a fallback.
expect(getVisibleChildren(container)).toEqual(
Loading...
);
// We already bootstrapped.
expect(bootstrapped).toBe(true);
// Attempt to hydrate the content.
await waitForAll([]);
// We're still loading because we're waiting for the server to stream more content.
expect(getVisibleChildren(container)).toEqual(
Loading...
);
// The server now updates the content in place in the fallback.
await act(() => {
resolveText('Hello');
});
// The final HTML is now in place.
expect(getVisibleChildren(container)).toEqual(
Hello
,
);
const h1 = container.getElementsByTagName('h1')[0];
// But it is not yet hydrated.
expect(ref.current).toBe(null);
await waitForAll([]);
// Now it's hydrated.
expect(ref.current).toBe(h1);
});
it('handles an error on the client if the server ends up erroring', async () => {
const ref = React.createRef();
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error) {
return {this.state.error.message};
}
return this.props.children;
}
}
function App() {
return (
);
}
const loggedErrors = [];
// We originally suspend the boundary and start streaming the loading state.
await act(() => {
const {pipe} = renderToPipeableStream(
,
{
onError(x) {
loggedErrors.push(x);
},
},
);
pipe(writable);
});
// We're still showing a fallback.
expect(getVisibleChildren(container)).toEqual(
Loading...
);
expect(loggedErrors).toEqual([]);
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, );
await waitForAll([]);
// We're still loading because we're waiting for the server to stream more content.
expect(getVisibleChildren(container)).toEqual(
Loading...
);
const theError = new Error('Error Message');
await act(() => {
rejectText('This Errors', theError);
});
expect(loggedErrors).toEqual([theError]);
// The server errored, but we still haven't hydrated. We don't know if the
// client will succeed yet, so we still show the loading state.
expect(getVisibleChildren(container)).toEqual(
Loading...
);
expect(ref.current).toBe(null);
// Flush the hydration.
await waitForAll([]);
// Hydrating should've generated an error and replaced the suspense boundary.
expect(getVisibleChildren(container)).toEqual(Error Message);
const b = container.getElementsByTagName('b')[0];
expect(ref.current).toBe(b);
});
// @gate enableSuspenseList
it('shows inserted items before pending in a SuspenseList as fallbacks while hydrating', async () => {
const ref = React.createRef();
// These are hoisted to avoid them from rerendering.
const a = (
);
const b = (
);
function App({showMore}) {
return (
{a}
{b}
{showMore ? (
C
) : null}
);
}
// We originally suspend the boundary and start streaming the loading state.
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
const root = ReactDOMClient.hydrateRoot(
container,
,
);
await waitForAll([]);
// We're not hydrated yet.
expect(ref.current).toBe(null);
expect(getVisibleChildren(container)).toEqual(
{'Loading A'}
{'Loading B'}
,
);
// Add more rows before we've hydrated the first two.
root.render();
await waitForAll([]);
// We're not hydrated yet.
expect(ref.current).toBe(null);
// We haven't resolved yet.
expect(getVisibleChildren(container)).toEqual(
,
);
const span = container.getElementsByTagName('span')[0];
expect(ref.current).toBe(span);
});
it('client renders a boundary if it does not resolve before aborting', async () => {
function App() {
return (
);
}
const loggedErrors = [];
const expectedDigest = 'Hash for Abort';
function onError(error) {
loggedErrors.push(error);
return expectedDigest;
}
let controls;
await act(() => {
controls = renderToPipeableStream(, {onError});
controls.pipe(writable);
});
// We're still showing a fallback.
const errors = [];
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error, errorInfo) {
errors.push({error, errorInfo});
},
});
await waitForAll([]);
// We're still loading because we're waiting for the server to stream more content.
expect(getVisibleChildren(container)).toEqual(
Loading...loading...
,
);
// We abort the server response.
await act(() => {
controls.abort();
});
// We still can't render it on the client.
await waitForAll([]);
expectErrors(
errors,
[
[
'Switched to client rendering because the server rendering aborted due to:\n\n' +
'The render was aborted by the server without a reason.',
expectedDigest,
// We get the stack of the task when it was aborted which is why we see `h1`
componentStack(['AsyncText', 'h1', 'Suspense', 'div', 'App']),
],
[
'Switched to client rendering because the server rendering aborted due to:\n\n' +
'The render was aborted by the server without a reason.',
expectedDigest,
componentStack(['AsyncText', 'Suspense', 'main', 'div', 'App']),
],
],
[
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
expectedDigest,
],
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
expectedDigest,
],
],
);
expect(getVisibleChildren(container)).toEqual(
Loading...loading...
,
);
// We now resolve it on the client.
await clientAct(() => {
resolveText('Hello');
resolveText('World');
});
assertLog([]);
// The client rendered HTML is now in place.
expect(getVisibleChildren(container)).toEqual(
Hello
World
,
);
});
it('should allow for two containers to be written to the same document', async () => {
// We create two passthrough streams for each container to write into.
// Notably we don't implement a end() call for these. Because we don't want to
// close the underlying stream just because one of the streams is done. Instead
// we manually close when both are done.
const writableA = new Stream.Writable();
writableA._write = (chunk, encoding, next) => {
writable.write(chunk, encoding, next);
};
const writableB = new Stream.Writable();
writableB._write = (chunk, encoding, next) => {
writable.write(chunk, encoding, next);
};
await act(() => {
const {pipe} = renderToPipeableStream(
// We use two nested boundaries to flush out coverage of an old reentrancy bug.
,
);
assertConsoleErrorDev([
' is using incorrect casing. Use PascalCase for React components, or lowercase for HTML elements.' +
'\n' +
' in inCorrectTag (at **)\n' +
' in C (at **)\n' +
' in A (at **)',
]);
await act(() => {
resolveText('Hello');
resolveText('World');
});
assertConsoleErrorDev([
'Each child in a list should have a unique "key" prop.\n\nCheck the render method of `B`.' +
' See https://react.dev/link/warning-keys for more information.\n' +
' in span (at **)\n' +
' in mapper (at **)\n' +
' in Array.map (at **)\n' +
' in B (at **)\n' +
' in A (at **)',
]);
expect(getVisibleChildren(container)).toEqual(
,
);
pipe(writable);
});
assertConsoleErrorDev([
'TestProvider uses the legacy childContextTypes API which will soon be removed. ' +
'Use React.createContext() instead. (https://react.dev/link/legacy-context)\n' +
' in TestProvider (at **)',
'TestConsumer uses the legacy contextTypes API which will soon be removed. ' +
'Use React.createContext() with static contextType instead. (https://react.dev/link/legacy-context)\n' +
' in TestConsumer (at **)',
]);
expect(getVisibleChildren(container)).toEqual(
,
);
});
it('client renders a boundary if it errors before finishing the fallback', async () => {
function App({isClient}) {
return (
}>
{isClient ? (
) : (
)}
);
}
const theError = new Error('Test');
const loggedErrors = [];
function onError(x) {
loggedErrors.push(x);
return `hash of (${x.message})`;
}
const expectedDigest = onError(theError);
loggedErrors.length = 0;
let controls;
await act(() => {
controls = renderToPipeableStream(
,
{
onError,
},
);
controls.pipe(writable);
});
// We're still showing a fallback.
const errors = [];
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error, errorInfo) {
errors.push({error, errorInfo});
},
});
await waitForAll([]);
// We're still loading because we're waiting for the server to stream more content.
expect(getVisibleChildren(container)).toEqual(
Loading root...
);
expect(loggedErrors).toEqual([]);
// Error the content, but we don't have a fallback yet.
await act(() => {
rejectText('Hello', theError);
});
expect(loggedErrors).toEqual([theError]);
// We still can't render it on the client because we haven't unblocked the parent.
await waitForAll([]);
expect(getVisibleChildren(container)).toEqual(
Loading root...
);
// Unblock the loading state
await act(() => {
resolveText('Loading...');
});
// Now we're able to show the inner boundary.
expect(getVisibleChildren(container)).toEqual(
Loading...
,
);
// That will let us client render it instead.
await waitForAll([]);
expectErrors(
errors,
[
[
'Switched to client rendering because the server rendering errored:\n\n' +
theError.message,
expectedDigest,
componentStack([
'AsyncText',
'h1',
'Suspense',
'div',
'Suspense',
'div',
'App',
]),
],
],
[
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
expectedDigest,
],
],
);
// The client rendered HTML is now in place.
expect(getVisibleChildren(container)).toEqual(
Hello
,
);
expect(loggedErrors).toEqual([theError]);
});
it('should be able to abort the fallback if the main content finishes first', async () => {
await act(() => {
const {pipe} = renderToPipeableStream(
);
// We should have received a partial segment containing the a partial of the fallback.
expect(container.innerHTML).toContain('Inner');
await act(() => {
resolveText('Hello');
});
// We should've been able to display the content without waiting for the rest of the fallback.
expect(getVisibleChildren(container)).toEqual(
Hello
,
);
});
it('calls getServerSnapshot instead of getSnapshot', async () => {
const ref = React.createRef();
function getServerSnapshot() {
return 'server';
}
function getClientSnapshot() {
return 'client';
}
function subscribe() {
return () => {};
}
function Child({text}) {
Scheduler.log(text);
return text;
}
function App() {
const value = useSyncExternalStore(
subscribe,
getClientSnapshot,
getServerSnapshot,
);
return (
);
}
const loggedErrors = [];
await act(() => {
const {pipe} = renderToPipeableStream(
,
{
onError(x) {
loggedErrors.push(x);
},
},
);
pipe(writable);
});
assertLog(['server']);
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
// The first paint switches to client rendering due to mismatch
await waitForPaint([
'client',
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
expect(getVisibleChildren(container)).toEqual(
client
);
});
// The selector implementation uses the lazy ref initialization pattern
it('calls getServerSnapshot instead of getSnapshot (with selector and isEqual)', async () => {
// Same as previous test, but with a selector that returns a complex object
// that is memoized with a custom `isEqual` function.
const ref = React.createRef();
function getServerSnapshot() {
return {env: 'server', other: 'unrelated'};
}
function getClientSnapshot() {
return {env: 'client', other: 'unrelated'};
}
function selector({env}) {
return {env};
}
function isEqual(a, b) {
return a.env === b.env;
}
function subscribe() {
return () => {};
}
function Child({text}) {
Scheduler.log(text);
return text;
}
function App() {
const {env} = useSyncExternalStoreWithSelector(
subscribe,
getClientSnapshot,
getServerSnapshot,
selector,
isEqual,
);
return (
);
}
const loggedErrors = [];
await act(() => {
const {pipe} = renderToPipeableStream(
,
{
onError(x) {
loggedErrors.push(x);
},
},
);
pipe(writable);
});
assertLog(['server']);
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
},
});
// The first paint uses the client due to mismatch forcing client render
// The first paint switches to client rendering due to mismatch
await waitForPaint([
'client',
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
expect(getVisibleChildren(container)).toEqual(
client
);
});
it(
'errors during hydration in the shell force a client render at the ' +
'root, and during the client render it recovers',
async () => {
let isClient = false;
function subscribe() {
return () => {};
}
function getClientSnapshot() {
return 'Yay!';
}
// At the time of writing, the only API that exposes whether it's currently
// hydrating is the `getServerSnapshot` API, so I'm using that here to
// simulate an error during hydration.
function getServerSnapshot() {
if (isClient) {
throw new Error('Hydration error');
}
return 'Yay!';
}
function Child() {
const value = useSyncExternalStore(
subscribe,
getClientSnapshot,
getServerSnapshot,
);
Scheduler.log(value);
return value;
}
const spanRef = React.createRef();
function App() {
return (
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
assertLog(['Yay!']);
const span = container.getElementsByTagName('span')[0];
// Hydrate the tree. Child will throw during hydration, but not when it
// falls back to client rendering.
isClient = true;
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
// An error logged but instead of surfacing it to the UI, we switched
// to client rendering.
await waitForAll([
'Yay!',
'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering the entire root.',
'Cause: Hydration error',
]);
expect(getVisibleChildren(container)).toEqual(Yay!);
// The node that's inside the boundary that errored during hydration was
// not hydrated.
expect(spanRef.current).not.toBe(span);
},
);
it('can hydrate uSES in StrictMode with different client and server snapshot (sync)', async () => {
function subscribe() {
return () => {};
}
function getClientSnapshot() {
return 'Yay!';
}
function getServerSnapshot() {
return 'Nay!';
}
function App() {
const value = useSyncExternalStore(
subscribe,
getClientSnapshot,
getServerSnapshot,
);
Scheduler.log(value);
return value;
}
const element = (
);
await act(async () => {
const {pipe} = renderToPipeableStream(element);
pipe(writable);
});
assertLog(['Nay!']);
expect(getVisibleChildren(container)).toEqual('Nay!');
await clientAct(() => {
ReactDOM.flushSync(() => {
ReactDOMClient.hydrateRoot(container, element);
});
});
expect(getVisibleChildren(container)).toEqual('Yay!');
assertLog(['Nay!', 'Yay!']);
});
it('can hydrate uSES in StrictMode with different client and server snapshot (concurrent)', async () => {
function subscribe() {
return () => {};
}
function getClientSnapshot() {
return 'Yay!';
}
function getServerSnapshot() {
return 'Nay!';
}
function App() {
const value = useSyncExternalStore(
subscribe,
getClientSnapshot,
getServerSnapshot,
);
Scheduler.log(value);
return value;
}
const element = (
);
await act(async () => {
const {pipe} = renderToPipeableStream(element);
pipe(writable);
});
assertLog(['Nay!']);
expect(getVisibleChildren(container)).toEqual('Nay!');
await clientAct(() => {
React.startTransition(() => {
ReactDOMClient.hydrateRoot(container, element);
});
});
expect(getVisibleChildren(container)).toEqual('Yay!');
assertLog(['Nay!', 'Yay!']);
});
it(
'errors during hydration force a client render at the nearest Suspense ' +
'boundary, and during the client render it recovers',
async () => {
let isClient = false;
function subscribe() {
return () => {};
}
function getClientSnapshot() {
return 'Yay!';
}
// At the time of writing, the only API that exposes whether it's currently
// hydrating is the `getServerSnapshot` API, so I'm using that here to
// simulate an error during hydration.
function getServerSnapshot() {
if (isClient) {
throw new Error('Hydration error');
}
return 'Yay!';
}
function Child() {
const value = useSyncExternalStore(
subscribe,
getClientSnapshot,
getServerSnapshot,
);
Scheduler.log(value);
return value;
}
const span1Ref = React.createRef();
const span2Ref = React.createRef();
const span3Ref = React.createRef();
function App() {
return (
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
assertLog(['Yay!']);
const [span1, span2, span3] = container.getElementsByTagName('span');
// Hydrate the tree. Child will throw during hydration, but not when it
// falls back to client rendering.
isClient = true;
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
// An error logged but instead of surfacing it to the UI, we switched
// to client rendering.
await waitForAll([
'Yay!',
'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.',
'Cause: Hydration error',
]);
expect(getVisibleChildren(container)).toEqual(
Yay!
,
);
// The node that's inside the boundary that errored during hydration was
// not hydrated.
expect(span2Ref.current).not.toBe(span2);
// But the nodes outside the boundary were.
expect(span1Ref.current).toBe(span1);
expect(span3Ref.current).toBe(span3);
},
);
it(
'errors during hydration force a client render at the nearest Suspense ' +
'boundary, and during the client render it fails again',
async () => {
// Similar to previous test, but the client render errors, too. We should
// be able to capture it with an error boundary.
let isClient = false;
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error !== null) {
return this.state.error.message;
}
return this.props.children;
}
}
function Child() {
if (isClient) {
throw new Error('Oops!');
}
Scheduler.log('Yay!');
return 'Yay!';
}
const span1Ref = React.createRef();
const span2Ref = React.createRef();
const span3Ref = React.createRef();
function App() {
return (
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
assertLog(['Yay!']);
// Hydrate the tree. Child will throw during render.
isClient = true;
const errors = [];
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
errors.push(error.message);
},
});
// Because we failed to recover from the error, onRecoverableError
// shouldn't be called.
await waitForAll([]);
expect(getVisibleChildren(container)).toEqual('Oops!');
expectErrors(errors, [], []);
},
);
// Disabled because of a WWW late mutations regression.
// We may want to re-enable this if we figure out why.
// @gate FIXME
it('does not recreate the fallback if server errors and hydration suspends', async () => {
let isClient = false;
function Child() {
if (isClient) {
readText('Yay!');
} else {
throw Error('Oops.');
}
Scheduler.log('Yay!');
return 'Yay!';
}
const fallbackRef = React.createRef();
function App() {
return (
Loading...}>
);
}
await act(() => {
const {pipe} = renderToPipeableStream(, {
onError(error) {
Scheduler.log('[s!] ' + error.message);
},
});
pipe(writable);
});
assertLog(['[s!] Oops.']);
// The server could not complete this boundary, so we'll retry on the client.
const serverFallback = container.getElementsByTagName('p')[0];
expect(serverFallback.innerHTML).toBe('Loading...');
// Hydrate the tree. This will suspend.
isClient = true;
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + error.message);
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
// This should not report any errors yet.
await waitForAll([]);
expect(getVisibleChildren(container)).toEqual(
Loading...
,
);
// Normally, hydrating after server error would force a clean client render.
// However, it suspended so at best we'd only get the same fallback anyway.
// We don't want to recreate the same fallback in the DOM again because
// that's extra work and would restart animations etc. Check we don't do that.
const clientFallback = container.getElementsByTagName('p')[0];
expect(serverFallback).toBe(clientFallback);
// When we're able to fully hydrate, we expect a clean client render.
await act(() => {
resolveText('Yay!');
});
await waitForAll([
'Yay!',
'onRecoverableError: The server could not finish this Suspense boundary, ' +
'likely due to an error during server rendering. ' +
'Switched to client rendering.',
]);
expect(getVisibleChildren(container)).toEqual(
Yay!
,
);
});
// Disabled because of a WWW late mutations regression.
// We may want to re-enable this if we figure out why.
// @gate FIXME
it(
'does not recreate the fallback if server errors and hydration suspends ' +
'and root receives a transition',
async () => {
let isClient = false;
function Child({color}) {
if (isClient) {
readText('Yay!');
} else {
throw Error('Oops.');
}
Scheduler.log('Yay! (' + color + ')');
return 'Yay! (' + color + ')';
}
const fallbackRef = React.createRef();
function App({color}) {
return (
Loading...}>
);
}
await act(() => {
const {pipe} = renderToPipeableStream(, {
onError(error) {
Scheduler.log('[s!] ' + error.message);
},
});
pipe(writable);
});
assertLog(['[s!] Oops.']);
// The server could not complete this boundary, so we'll retry on the client.
const serverFallback = container.getElementsByTagName('p')[0];
expect(serverFallback.innerHTML).toBe('Loading...');
// Hydrate the tree. This will suspend.
isClient = true;
const root = ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + error.message);
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
// This should not report any errors yet.
await waitForAll([]);
expect(getVisibleChildren(container)).toEqual(
Loading...
,
);
// Normally, hydrating after server error would force a clean client render.
// However, it suspended so at best we'd only get the same fallback anyway.
// We don't want to recreate the same fallback in the DOM again because
// that's extra work and would restart animations etc. Check we don't do that.
const clientFallback = container.getElementsByTagName('p')[0];
expect(serverFallback).toBe(clientFallback);
// Transition updates shouldn't recreate the fallback either.
React.startTransition(() => {
root.render();
});
await waitForAll([]);
jest.runAllTimers();
const clientFallback2 = container.getElementsByTagName('p')[0];
expect(clientFallback2).toBe(serverFallback);
// When we're able to fully hydrate, we expect a clean client render.
await act(() => {
resolveText('Yay!');
});
await waitForAll([
'Yay! (red)',
'onRecoverableError: The server could not finish this Suspense boundary, ' +
'likely due to an error during server rendering. ' +
'Switched to client rendering.',
'Yay! (blue)',
]);
expect(getVisibleChildren(container)).toEqual(
Yay! (blue)
,
);
},
);
// Disabled because of a WWW late mutations regression.
// We may want to re-enable this if we figure out why.
// @gate FIXME
it(
'recreates the fallback if server errors and hydration suspends but ' +
'client receives new props',
async () => {
let isClient = false;
function Child() {
const value = 'Yay!';
if (isClient) {
readText(value);
} else {
throw Error('Oops.');
}
Scheduler.log(value);
return value;
}
const fallbackRef = React.createRef();
function App({fallbackText}) {
return (
{fallbackText}}>
);
}
await act(() => {
const {pipe} = renderToPipeableStream(
,
{
onError(error) {
Scheduler.log('[s!] ' + error.message);
},
},
);
pipe(writable);
});
assertLog(['[s!] Oops.']);
const serverFallback = container.getElementsByTagName('p')[0];
expect(serverFallback.innerHTML).toBe('Loading...');
// Hydrate the tree. This will suspend.
isClient = true;
const root = ReactDOMClient.hydrateRoot(
container,
,
{
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + error.message);
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
},
);
// This should not report any errors yet.
await waitForAll([]);
expect(getVisibleChildren(container)).toEqual(
Loading...
,
);
// Normally, hydration after server error would force a clean client render.
// However, that suspended so at best we'd only get a fallback anyway.
// We don't want to replace a fallback with the same fallback because
// that's extra work and would restart animations etc. Verify we don't do that.
const clientFallback1 = container.getElementsByTagName('p')[0];
expect(serverFallback).toBe(clientFallback1);
// However, an update may have changed the fallback props. In that case we have to
// actually force it to re-render on the client and throw away the server one.
root.render();
await waitForAll([]);
jest.runAllTimers();
assertLog([
'onRecoverableError: The server could not finish this Suspense boundary, ' +
'likely due to an error during server rendering. ' +
'Switched to client rendering.',
]);
expect(getVisibleChildren(container)).toEqual(
More loading...
,
);
// This should be a clean render without reusing DOM.
const clientFallback2 = container.getElementsByTagName('p')[0];
expect(clientFallback2).not.toBe(clientFallback1);
// Verify we can still do a clean content render after.
await act(() => {
resolveText('Yay!');
});
await waitForAll(['Yay!']);
expect(getVisibleChildren(container)).toEqual(
Yay!
,
);
},
);
it(
'errors during hydration force a client render at the nearest Suspense ' +
'boundary, and during the client render it recovers, then a deeper ' +
'child suspends',
async () => {
let isClient = false;
function subscribe() {
return () => {};
}
function getClientSnapshot() {
return 'Yay!';
}
// At the time of writing, the only API that exposes whether it's currently
// hydrating is the `getServerSnapshot` API, so I'm using that here to
// simulate an error during hydration.
function getServerSnapshot() {
if (isClient) {
throw new Error('Hydration error');
}
return 'Yay!';
}
function Child() {
const value = useSyncExternalStore(
subscribe,
getClientSnapshot,
getServerSnapshot,
);
if (isClient) {
readText(value);
}
Scheduler.log(value);
return value;
}
const span1Ref = React.createRef();
const span2Ref = React.createRef();
const span3Ref = React.createRef();
function App() {
return (
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
assertLog(['Yay!']);
const [span1, span2, span3] = container.getElementsByTagName('span');
// Hydrate the tree. Child will throw during hydration, but not when it
// falls back to client rendering.
isClient = true;
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
// An error logged but instead of surfacing it to the UI, we switched
// to client rendering.
await waitForAll([
'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.',
'Cause: Hydration error',
]);
expect(getVisibleChildren(container)).toEqual(
,
);
// The node that's inside the boundary that errored during hydration was
// not hydrated.
expect(span2Ref.current).not.toBe(span2);
// But the nodes outside the boundary were.
expect(span1Ref.current).toBe(span1);
expect(span3Ref.current).toBe(span3);
},
);
it('logs regular (non-hydration) errors when the UI recovers', async () => {
let shouldThrow = true;
function A({unused}) {
if (shouldThrow) {
Scheduler.log('Oops!');
throw new Error('Oops!');
}
Scheduler.log('A');
return 'A';
}
function B() {
Scheduler.log('B');
return 'B';
}
function App() {
return (
<>
>
);
}
const root = ReactDOMClient.createRoot(container, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
React.startTransition(() => {
root.render();
});
// Partially render A, but yield before the render has finished
await waitFor(['Oops!']);
// React will try rendering again synchronously. During the retry, A will
// not throw. This simulates a concurrent data race that is fixed by
// blocking the main thread.
shouldThrow = false;
await waitForAll([
// Render again, synchronously
'A',
'B',
// Log the error
'onRecoverableError: There was an error during concurrent rendering but React was able to recover by instead synchronously rendering the entire root.',
'Cause: Oops!',
]);
// UI looks normal
expect(container.textContent).toEqual('AB');
});
it('logs multiple hydration errors in the same render', async () => {
let isClient = false;
function subscribe() {
return () => {};
}
function getClientSnapshot() {
return 'Yay!';
}
function getServerSnapshot() {
if (isClient) {
throw new Error('Hydration error');
}
return 'Yay!';
}
function Child({label}) {
// This will throw during client hydration. Only reason to use
// useSyncExternalStore in this test is because getServerSnapshot has the
// ability to observe whether we're hydrating.
useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);
Scheduler.log(label);
return label;
}
function App() {
return (
<>
>
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
assertLog(['A', 'B']);
// Hydrate the tree. Child will throw during hydration, but not when it
// falls back to client rendering.
isClient = true;
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([
'A',
'B',
'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.',
'Cause: Hydration error',
'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.',
'Cause: Hydration error',
]);
});
it('supports iterable', async () => {
const Immutable = require('immutable');
const mappedJSX = Immutable.fromJS([
{name: 'a', value: 'a'},
{name: 'b', value: 'b'},
]).map(item =>
,
);
pipe(writable);
});
// Each act retries once which causes a new ping which schedules
// new work but only after the act has finished rendering.
await act(() => {});
await act(() => {});
await act(() => {});
await act(() => {});
expect(getVisibleChildren(container)).toEqual(
;
}
await act(() => {
const {pipe} = renderToPipeableStream({iterable});
pipe(writable);
});
// Each act retries once which causes a new ping which schedules
// new work but only after the act has finished rendering.
await act(() => {});
await act(() => {});
await act(() => {});
await act(() => {});
expect(getVisibleChildren(container)).toEqual(
);
});
it('Supports custom abort reasons with a string', async () => {
function App() {
return (
);
}
let abort;
const loggedErrors = [];
await act(() => {
const {pipe, abort: abortImpl} = renderToPipeableStream(, {
onError(error) {
// In this test we contrive erroring with strings so we push the error whereas in most
// other tests we contrive erroring with Errors and push the message.
loggedErrors.push(error);
return 'a digest';
},
});
abort = abortImpl;
pipe(writable);
});
expect(loggedErrors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
p
span
,
);
await act(() => {
abort('foobar');
});
expect(loggedErrors).toEqual(['foobar', 'foobar']);
const errors = [];
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error, errorInfo) {
errors.push({error, errorInfo});
},
});
await waitForAll([]);
expectErrors(
errors,
[
[
'Switched to client rendering because the server rendering aborted due to:\n\n' +
'foobar',
'a digest',
componentStack(['AsyncText', 'Suspense', 'p', 'div', 'App']),
],
[
'Switched to client rendering because the server rendering aborted due to:\n\n' +
'foobar',
'a digest',
componentStack(['AsyncText', 'Suspense', 'span', 'div', 'App']),
],
],
[
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
'a digest',
],
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
'a digest',
],
],
);
});
it('Supports custom abort reasons with an Error', async () => {
function App() {
return (
,
{
onRecoverableError(error, errorInfo) {
expect(error.digest).toBe('a digest');
expect(errorInfo.digest).toBe(undefined);
assertConsoleErrorDev([
'You are accessing "digest" from the errorInfo object passed to onRecoverableError.' +
' This property is no longer provided as part of errorInfo but can be accessed as a property' +
' of the Error instance itself.',
]);
},
},
);
await waitForAll([]);
});
it('takes an importMap option which emits an "importmap" script in the head', async () => {
const importMap = {
foo: './path/to/foo.js',
};
await act(() => {
renderToPipeableStream(
,
);
});
// https://github.com/facebook/react/issues/27540
// This test is not actually asserting much because there is possibly a bug in the closeing logic for the
// Node implementation of Fizz. The close leads to an abort which sets the destination to null before the Float
// method has an opportunity to schedule a write. We should fix this probably and once we do this test will start
// to fail if the underyling issue of writing after stream completion isn't fixed
it('does not try to write to the stream after it has been closed', async () => {
async function preloadLate() {
await 1;
ReactDOM.preconnect('foo');
}
function Preload() {
preloadLate();
return null;
}
function App() {
return (
hello
);
}
await act(() => {
renderToPipeableStream().pipe(writable);
});
expect(getVisibleChildren(document)).toEqual(
hello
,
);
});
it('provides headers after initial work if onHeaders option used', async () => {
let headers = null;
function onHeaders(x) {
headers = x;
}
function Preloads() {
ReactDOM.preload('font2', {as: 'font'});
ReactDOM.preload('imagepre2', {as: 'image', fetchPriority: 'high'});
ReactDOM.preconnect('pre2', {crossOrigin: 'use-credentials'});
ReactDOM.prefetchDNS('dns2');
}
function Blocked() {
readText('blocked');
return (
<>
>
);
}
function App() {
ReactDOM.preload('font', {as: 'font'});
ReactDOM.preload('imagepre', {as: 'image', fetchPriority: 'high'});
ReactDOM.preconnect('pre', {crossOrigin: 'use-credentials'});
ReactDOM.prefetchDNS('dns');
return (
);
}
await act(() => {
renderToPipeableStream(, {onHeaders});
});
expect(headers).toEqual({
Link: `
; rel=preconnect; crossorigin="use-credentials",
; rel=dns-prefetch,
; rel=preload; as="font"; crossorigin="",
; rel=preload; as="image"; fetchpriority="high",
; rel=preload; as="image"
`
.replaceAll('\n', '')
.trim(),
});
});
it('omits images from preload headers if they contain srcset and sizes', async () => {
let headers = null;
function onHeaders(x) {
headers = x;
}
function App() {
ReactDOM.preload('responsive-preload-set-only', {
as: 'image',
fetchPriority: 'high',
imageSrcSet: 'srcset',
});
ReactDOM.preload('responsive-preload', {
as: 'image',
fetchPriority: 'high',
imageSrcSet: 'srcset',
imageSizes: 'sizes',
});
ReactDOM.preload('non-responsive-preload', {
as: 'image',
fetchPriority: 'high',
});
return (
);
}
await act(() => {
renderToPipeableStream(, {onHeaders});
});
expect(headers).toEqual({
Link: `
; rel=preload; as="image"; fetchpriority="high",
; rel=preload; as="image"; fetchpriority="high"
`
.replaceAll('\n', '')
.trim(),
});
});
it('preserves referrerPolicy for image preload headers', async () => {
let headers = null;
function onHeaders(x) {
headers = x;
}
function App() {
return (
);
}
await act(() => {
renderToPipeableStream(, {onHeaders});
});
expect(headers).toEqual({
Link: `; rel=preload; as="image"; fetchpriority="high"; referrerpolicy="no-referrer"`,
});
});
it('emits nothing for headers if you pipe before work begins', async () => {
let headers = null;
function onHeaders(x) {
headers = x;
}
function App() {
ReactDOM.preload('presrc', {
as: 'image',
fetchPriority: 'high',
imageSrcSet: 'presrcset',
imageSizes: 'presizes',
});
return (
);
}
await act(() => {
renderToPipeableStream(, {onHeaders}).pipe(writable);
});
expect(headers).toEqual({});
});
it('stops accumulating new headers once the maxHeadersLength limit is satisifed', async () => {
let headers = null;
function onHeaders(x) {
headers = x;
}
function App() {
ReactDOM.preconnect('foo');
ReactDOM.preconnect('bar');
ReactDOM.preconnect('baz');
return (
hello
);
}
await act(() => {
renderToPipeableStream(, {onHeaders, maxHeadersLength: 44});
});
expect(headers).toEqual({
Link: `
; rel=preconnect,
; rel=preconnect
`
.replaceAll('\n', '')
.trim(),
});
});
it('logs an error if onHeaders throws but continues the render', async () => {
const errors = [];
function onError(error) {
errors.push(error.message);
}
function onHeaders(x) {
throw new Error('bad onHeaders');
}
let pipe;
await act(() => {
({pipe} = renderToPipeableStream(
,
);
expect(
stripExternalRuntimeInNodes(
document.getElementsByTagName('script'),
renderOptions.unstable_externalRuntimeSrc,
).map(n => n.outerHTML),
).toEqual([
'',
'',
'',
'',
'',
'',
'',
]);
});
describe('inline script escaping', () => {
describe('bootstrapScriptContent', () => {
it('the "S" in "?[Ss]cript" strings are replaced with unicode escaped lowercase s or S depending on case, preserving case sensitivity of nearby characters', async () => {
window.__test_outlet = '';
const stringWithScriptsInIt =
'prescription pre
window.__test_outlet = 'safe';
-->
`},
);
pipe(writable);
});
expect(window.__test_outlet).toBe('safe');
});
});
});
describe(',
);
pipe(writable);
});
expect(window.getComputedStyle(document.body).backgroundColor).toMatch(
'rgb(0, 0, 255)',
);
});
it('the "S" in "?[Ss]style" strings are replaced with unicode escaped lowercase s or S depending on case, preserving case sensitivity of nearby characters inside hoistable style tags', async () => {
await act(() => {
const {pipe} = renderToPipeableStream(
<>
>,
);
pipe(writable);
});
expect(window.getComputedStyle(document.body).backgroundColor).toMatch(
'rgb(255, 0, 0)',
);
});
});
// @gate enableFizzExternalRuntime
it('supports option to load runtime as an external script', async () => {
await act(() => {
const {pipe} = renderToPipeableStream(
,
{
unstable_externalRuntimeSrc: 'src-of-external-runtime',
},
);
pipe(writable);
});
// We want the external runtime to be sent in so the script can be
// fetched and executed as early as possible. For SSR pages using Suspense,
// this script execution would be render blocking.
expect(
Array.from(document.head.getElementsByTagName('script')).map(
n => n.outerHTML,
),
).toEqual(['']);
expect(getVisibleChildren(document)).toEqual(
loading...
,
);
});
// @gate shouldUseFizzExternalRuntime
it('does not send script tags for SSR instructions when using the external runtime', async () => {
function App() {
return (
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
await act(() => {
resolveText('Hello');
});
// The only script elements sent should be from unstable_externalRuntimeSrc
expect(document.getElementsByTagName('script').length).toEqual(1);
});
// @gate shouldUseFizzExternalRuntime
it('does (unfortunately) send the external runtime for static pages', async () => {
await act(() => {
const {pipe} = renderToPipeableStream(
hello world!
,
);
pipe(writable);
});
// no scripts should be sent
expect(document.getElementsByTagName('script').length).toEqual(1);
// the html should be as-is
expect(document.documentElement.innerHTML).toEqual(
'' +
(gate(flags => flags.enableFizzBlockingRender)
? ''
: '') +
'
hello world!
' +
(gate(flags => flags.enableFizzBlockingRender)
? ''
: '') +
'',
);
});
it('#24384: Suspending should halt hydration warnings and not emit any if hydration completes successfully after unsuspending', async () => {
const makeApp = () => {
let resolve, resolved;
const promise = new Promise(r => {
resolve = () => {
resolved = true;
return r();
};
});
function ComponentThatSuspends() {
if (!resolved) {
throw promise;
}
return
,
);
// Now that the boundary resolves to it's children the hydration completes and discovers that there is a mismatch requiring
// client-side rendering.
await clientResolve();
await waitForAll([]);
expect(getVisibleChildren(container)).toEqual(
A
world
,
);
});
it('#24384: Suspending should halt hydration warnings but still emit hydration warnings after unsuspending if mismatches are genuine', async () => {
const makeApp = () => {
let resolve, resolved;
const promise = new Promise(r => {
resolve = () => {
resolved = true;
return r();
};
});
function ComponentThatSuspends() {
if (!resolved) {
throw promise;
}
return
,
);
// The client app is rendered with an intentionally incorrect text. The still Suspended component causes
// hydration to fail silently (allowing for cache warming but otherwise skipping this boundary) until it
// resolves.
const [ClientApp, clientResolve] = makeApp();
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([]);
expect(getVisibleChildren(container)).toEqual(
A
initial
,
);
// Now that the boundary resolves to it's children the hydration completes and discovers that there is a mismatch requiring
// client-side rendering.
await clientResolve();
await waitForAll([
"onRecoverableError: Hydration failed because the server rendered text didn't match the client.",
]);
expect(getVisibleChildren(container)).toEqual(
A
replaced
,
);
await waitForAll([]);
});
it('only warns once on hydration mismatch while within a suspense boundary', async () => {
const App = ({text}) => {
return (
,
);
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([
"onRecoverableError: Hydration failed because the server rendered text didn't match the client.",
]);
expect(getVisibleChildren(container)).toEqual(
replaced
replaced
replaced
,
);
await waitForAll([]);
});
it('supresses hydration warnings when an error occurs within a Suspense boundary', async () => {
let isClient = false;
function ThrowWhenHydrating({children}) {
// This is a trick to only throw if we're hydrating, because
// useSyncExternalStore calls getServerSnapshot instead of the regular
// getSnapshot in that case.
useSyncExternalStore(
() => {},
t => t,
() => {
if (isClient) {
throw new Error('uh oh');
}
},
);
return children;
}
const App = () => {
return (
,
);
isClient = true;
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([
'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.',
'Cause: uh oh',
]);
expect(getVisibleChildren(container)).toEqual(
one
two
five
,
);
await waitForAll([]);
});
it('does not log for errors after the first hydration error', async () => {
let isClient = false;
function ThrowWhenHydrating({children, message}) {
// This is a trick to only throw if we're hydrating, because
// useSyncExternalStore calls getServerSnapshot instead of the regular
// getSnapshot in that case.
useSyncExternalStore(
() => {},
t => t,
() => {
if (isClient) {
Scheduler.log('throwing: ' + message);
throw new Error(message);
}
},
);
return children;
}
const App = () => {
return (
,
);
isClient = true;
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([
'throwing: first error',
// onRecoverableError because the UI recovered without surfacing the
// error to the user.
'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.',
'Cause: first error',
]);
expect(getVisibleChildren(container)).toEqual(
one
two
three
,
);
await waitForAll([]);
});
it('does not log for errors after a preceding fiber suspends', async () => {
let isClient = false;
let promise = null;
let unsuspend = null;
let isResolved = false;
function ComponentThatSuspendsOnClient() {
if (isClient && !isResolved) {
if (promise === null) {
promise = new Promise(resolve => {
unsuspend = () => {
isResolved = true;
resolve();
};
});
}
Scheduler.log('suspending');
throw promise;
}
return null;
}
function ThrowWhenHydrating({children, message}) {
// This is a trick to only throw if we're hydrating, because
// useSyncExternalStore calls getServerSnapshot instead of the regular
// getSnapshot in that case.
useSyncExternalStore(
() => {},
t => t,
() => {
if (isClient) {
Scheduler.log('throwing: ' + message);
throw new Error(message);
}
},
);
return children;
}
const App = () => {
return (
,
);
await unsuspend();
await waitForAll([
'throwing: first error',
'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.',
'Cause: first error',
]);
expect(getVisibleChildren(container)).toEqual(
one
two
three
,
);
});
it('(outdated behavior) suspending after erroring will cause errors previously queued to be silenced until the boundary resolves', async () => {
// NOTE: This test was originally written to test a scenario that doesn't happen
// anymore. If something errors during hydration, we immediately unwind the
// stack and revert to client rendering. I've kept the test around just to
// demonstrate what actually happens in this sequence of events.
let isClient = false;
let promise = null;
let unsuspend = null;
let isResolved = false;
function ComponentThatSuspendsOnClient() {
if (isClient && !isResolved) {
if (promise === null) {
promise = new Promise(resolve => {
unsuspend = () => {
isResolved = true;
resolve();
};
});
}
Scheduler.log('suspending');
throw promise;
}
return null;
}
function ThrowWhenHydrating({children, message}) {
// This is a trick to only throw if we're hydrating, because
// useSyncExternalStore calls getServerSnapshot instead of the regular
// getSnapshot in that case.
useSyncExternalStore(
() => {},
t => t,
() => {
if (isClient) {
Scheduler.log('throwing: ' + message);
throw new Error(message);
}
},
);
return children;
}
const App = () => {
return (
,
);
isClient = true;
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([
'throwing: first error',
'suspending',
'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.',
'Cause: first error',
]);
expect(getVisibleChildren(container)).toEqual(
Loading...
,
);
await clientAct(() => unsuspend());
// Since our client components only throw on the very first render there are no
// new throws in this pass
assertLog([]);
expect(getVisibleChildren(container)).toEqual(
one
two
three
,
);
});
it('#24578 Hydration errors caused by a suspending component should not become recoverable when nested in an ancestor Suspense that is showing primary content', async () => {
// this test failed before because hydration errors on the inner boundary were upgraded to recoverable by
// a codepath of the outer boundary
function App({isClient}) {
return (
,
);
});
it('hydration warnings for mismatched text with multiple text nodes caused by suspending should be suppressed', async () => {
let resolve;
const Lazy = React.lazy(() => {
return new Promise(r => {
resolve = r;
});
});
function App({isClient}) {
return (
,
);
});
it('can emit the preamble even if the head renders asynchronously', async () => {
function AsyncNoOutput() {
readText('nooutput');
return null;
}
function AsyncHead() {
readText('head');
return (
a title
);
}
function AsyncBody() {
readText('body');
return (
hello
);
}
await act(() => {
const {pipe} = renderToPipeableStream(
,
);
pipe(writable);
});
await act(() => {
resolveText('body');
});
await act(() => {
resolveText('nooutput');
});
await act(() => {
resolveText('head');
});
expect(getVisibleChildren(document)).toEqual(
a title
hello
,
);
});
it('holds back body and html closing tags (the postamble) until all pending tasks are completed', async () => {
const chunks = [];
writable.on('data', chunk => {
chunks.push(chunk);
});
await act(() => {
const {pipe} = renderToPipeableStream(
first
,
);
pipe(writable);
});
expect(getVisibleChildren(document)).toEqual(
{'first'}
,
);
await act(() => {
resolveText('second');
});
expect(getVisibleChildren(document)).toEqual(
{'first'}
{'second'}
,
);
expect(chunks.pop()).toEqual('');
});
describe('text separators', () => {
// To force performWork to start before resolving AsyncText but before piping we need to wait until
// after scheduleWork which currently uses setImmediate to delay performWork
function afterImmediate() {
return new Promise(resolve => {
setImmediate(resolve);
});
}
it('only includes separators between adjacent text nodes', async () => {
function App({name}) {
return (
,
);
});
it('should not insert separators for text inside Suspense boundaries even if they would otherwise be considered text-embedded', async () => {
function App() {
return (
{'start'}
{'firststart'}
{'first suspended'}
{'firstend'}
{'secondstart'}
second suspended
{'end'}
,
);
});
it('(only) includes extraneous text separators in segments that complete before flushing, followed by nothing or a non-Text node', async () => {
function App() {
return (
{/* first boundary */}
{'hello'}
{'world'}
{/* second boundary */}
{'world'}
{/* third boundary */}
{'hello'}
{'world'}
{/* fourth boundary */}
{'world'}
,
);
});
});
describe('title children', () => {
it('should accept a single string child', async () => {
// a Single string child
function App() {
return (
hello
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
expect(getVisibleChildren(document.head)).toEqual(hello);
const errors = [];
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
errors.push(error.message);
},
});
await waitForAll([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(document.head)).toEqual(hello);
});
it('should accept a single number child', async () => {
// a Single number child
function App() {
return (
4
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
expect(getVisibleChildren(document.head)).toEqual(4);
const errors = [];
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
errors.push(error.message);
},
});
await waitForAll([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(document.head)).toEqual(4);
});
it('should accept a single bigint child', async () => {
// a Single number child
function App() {
return (
5n
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
expect(getVisibleChildren(document.head)).toEqual(5n);
const errors = [];
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
errors.push(error.message);
},
});
await waitForAll([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(document.head)).toEqual(5n);
});
it('should accept children array of length 1 containing a string', async () => {
// a Single string child
function App() {
return (
{['hello']}
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
expect(getVisibleChildren(document.head)).toEqual(hello);
const errors = [];
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
errors.push(error.message);
},
});
await waitForAll([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(document.head)).toEqual(hello);
});
it('should warn in dev when given an array of length 2 or more', async () => {
function App() {
return (
{['hello1', 'hello2']}
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
assertConsoleErrorDev([
'React expects the `children` prop of tags to be a string, number, bigint, ' +
'or object with a novel `toString` method but found an Array with length 2 instead. ' +
'Browsers treat all child Nodes of tags as Text content and React expects ' +
'to be able to convert `children` of tags to a single string value which is why ' +
'Arrays of length greater than 1 are not supported. ' +
'When using JSX it can be common to combine text nodes and value nodes. ' +
'For example: hello {nameOfUser}. ' +
'While not immediately apparent, `children` in this case is an Array with length 2. ' +
'If your `children` prop is using this form try rewriting it using a template string: ' +
'{`hello ${nameOfUser}`}.\n' +
' in title (at **)\n' +
' in App (at **)',
]);
expect(getVisibleChildren(document.head)).toEqual();
const errors = [];
ReactDOMClient.hydrateRoot(document.head, , {
onRecoverableError(error) {
errors.push(error.message);
},
});
await waitForAll([]);
expect(errors).toEqual([]);
// with float, the title doesn't render on the client or on the server
expect(getVisibleChildren(document.head)).toEqual();
});
it('should warn in dev if you pass a React Component as a child to ', async () => {
function IndirectTitle() {
return 'hello';
}
function App() {
return (
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
assertConsoleErrorDev([
'React expects the `children` prop of tags to be a string, number, bigint, ' +
'or object with a novel `toString` method but found an object that appears to be a ' +
'React element which never implements a suitable `toString` method. ' +
'Browsers treat all child Nodes of tags as Text content and React expects ' +
'to be able to convert children of tags to a single string value which is ' +
'why rendering React elements is not supported. If the `children` of is a ' +
'React Component try moving the tag into that component. ' +
'If the `children` of is some HTML markup change it to be Text only to be valid HTML.\n' +
' in title (at **)\n' +
' in App (at **)',
]);
// object titles are toStringed when float is on
expect(getVisibleChildren(document.head)).toEqual(
{'[object Object]'},
);
const errors = [];
ReactDOMClient.hydrateRoot(document.head, , {
onRecoverableError(error) {
errors.push(error.message);
},
});
await waitForAll([]);
expect(errors).toEqual([]);
// object titles are toStringed when float is on
expect(getVisibleChildren(document.head)).toEqual(
{'[object Object]'},
);
});
it('should warn in dev if you pass an object that does not implement toString as a child to ', async () => {
function App() {
return (
{{}}
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
assertConsoleErrorDev([
'React expects the `children` prop of tags to be a string, number, bigint, ' +
'or object with a novel `toString` method but found an object that does not implement a ' +
'suitable `toString` method. Browsers treat all child Nodes of tags as Text ' +
'content and React expects to be able to convert children of tags to a single string value. ' +
'Using the default `toString` method available on every object is almost certainly an error. ' +
'Consider whether the `children` of this is an object in error and change it to a ' +
'string or number value if so. Otherwise implement a `toString` method that React can ' +
'use to produce a valid .\n' +
' in title (at **)\n' +
' in App (at **)',
]);
// object titles are toStringed when float is on
expect(getVisibleChildren(document.head)).toEqual(
{'[object Object]'},
);
const errors = [];
ReactDOMClient.hydrateRoot(document.head, , {
onRecoverableError(error) {
errors.push(error.message);
},
});
await waitForAll([]);
expect(errors).toEqual([]);
// object titles are toStringed when float is on
expect(getVisibleChildren(document.head)).toEqual(
{'[object Object]'},
);
});
});
it('basic use(promise)', async () => {
const promiseA = Promise.resolve('A');
const promiseB = Promise.resolve('B');
const promiseC = Promise.resolve('C');
function Async() {
return use(promiseA) + use(promiseB) + use(promiseC);
}
function App() {
return (
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
// TODO: The `act` implementation in this file doesn't unwrap microtasks
// automatically. We can't use the same `act` we use for Fiber tests
// because that relies on the mock Scheduler. Doesn't affect any public
// API but we might want to fix this for our own internal tests.
//
// For now, wait for each promise in sequence.
await act(async () => {
await promiseA;
});
await act(async () => {
await promiseB;
});
await act(async () => {
await promiseC;
});
expect(getVisibleChildren(container)).toEqual('ABC');
ReactDOMClient.hydrateRoot(container, );
await waitForAll([]);
expect(getVisibleChildren(container)).toEqual('ABC');
});
it('basic use(context)', async () => {
const ContextA = React.createContext('default');
const ContextB = React.createContext('B');
function Client() {
return use(ContextA) + use(ContextB);
}
function App() {
return (
<>
>
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual('AB');
// Hydration uses a different renderer runtime (Fiber instead of Fizz).
// We reset _currentRenderer here to not trigger a warning about multiple
// renderers concurrently using these contexts
ContextA._currentRenderer = null;
ReactDOMClient.hydrateRoot(container, );
await waitForAll([]);
expect(getVisibleChildren(container)).toEqual('AB');
});
it('use(promise) in multiple components', async () => {
const promiseA = Promise.resolve('A');
const promiseB = Promise.resolve('B');
const promiseC = Promise.resolve('C');
const promiseD = Promise.resolve('D');
function Child({prefix}) {
return prefix + use(promiseC) + use(promiseD);
}
function Parent() {
return ;
}
function App() {
return (
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
// TODO: The `act` implementation in this file doesn't unwrap microtasks
// automatically. We can't use the same `act` we use for Fiber tests
// because that relies on the mock Scheduler. Doesn't affect any public
// API but we might want to fix this for our own internal tests.
//
// For now, wait for each promise in sequence.
await act(async () => {
await promiseA;
});
await act(async () => {
await promiseB;
});
await act(async () => {
await promiseC;
});
await act(async () => {
await promiseD;
});
expect(getVisibleChildren(container)).toEqual('ABCD');
ReactDOMClient.hydrateRoot(container, );
await waitForAll([]);
expect(getVisibleChildren(container)).toEqual('ABCD');
});
it('using a rejected promise will throw', async () => {
const promiseA = Promise.resolve('A');
const promiseB = Promise.reject(new Error('Oops!'));
const promiseC = Promise.resolve('C');
// Jest/Node will raise an unhandled rejected error unless we await this. It
// works fine in the browser, though.
await expect(promiseB).rejects.toThrow('Oops!');
function Async() {
return use(promiseA) + use(promiseB) + use(promiseC);
}
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error) {
return this.state.error.message;
}
return this.props.children;
}
}
function App() {
return (
);
}
const reportedServerErrors = [];
await act(() => {
const {pipe} = renderToPipeableStream(, {
onError(error) {
reportedServerErrors.push(error);
},
});
pipe(writable);
});
// TODO: The `act` implementation in this file doesn't unwrap microtasks
// automatically. We can't use the same `act` we use for Fiber tests
// because that relies on the mock Scheduler. Doesn't affect any public
// API but we might want to fix this for our own internal tests.
//
// For now, wait for each promise in sequence.
await act(async () => {
await promiseA;
});
await act(async () => {
await expect(promiseB).rejects.toThrow('Oops!');
});
await act(async () => {
await promiseC;
});
expect(getVisibleChildren(container)).toEqual(
);
// Because this is rethrown on the client, it is not a recoverable error.
expect(reportedClientErrors.length).toBe(0);
// It is caught by the error boundary.
expect(reportedCaughtErrors.length).toBe(1);
expect(reportedCaughtErrors[0].message).toBe('Oops!');
});
it("use a promise that's already been instrumented and resolved", async () => {
const thenable = {
status: 'fulfilled',
value: 'Hi',
then() {},
};
// This will never suspend because the thenable already resolved
function App() {
return use(thenable);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual('Hi');
ReactDOMClient.hydrateRoot(container, );
await waitForAll([]);
expect(getVisibleChildren(container)).toEqual('Hi');
});
it('unwraps thenable that fulfills synchronously without suspending', async () => {
function App() {
const thenable = {
then(resolve) {
// This thenable immediately resolves, synchronously, without waiting
// a microtask.
resolve('Hi');
},
};
try {
return ;
} catch {
throw new Error(
'`use` should not suspend because the thenable resolved synchronously.',
);
}
}
// Because the thenable resolves synchronously, we should be able to finish
// rendering synchronously, with no fallback.
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual('Hi');
});
// Regression: finishedTask aborting remaining fallback tasks from a
// completed boundary could reenter itself via abortTaskSoft and fire
// onAllReady twice (the inner call drained allPendingTasks to 0 and
// called completeAll, then the outer call re-observed the same 0).
it('only fires onAllReady once when a boundary with an instrumented sync-resolving thenable completes', async () => {
// Mirrors Flight-client chunk behavior: the status-probe .then() in
// trackUsedThenable stays pending, but the ping-attaching .then() in
// renderNode's catch resolves synchronously. This reorders the work
// queue so the fallback task is still in fallbackAbortableTasks when
// the content task completes.
function createDeferredSyncThenable(value) {
let thenCallCount = 0;
return {
status: 'pending',
value: undefined,
then(resolve) {
thenCallCount++;
if (thenCallCount > 1) {
this.status = 'fulfilled';
this.value = value;
resolve(value);
}
},
};
}
const thenable = createDeferredSyncThenable('hello');
function AsyncContent() {
return ;
}
let allReadyCount = 0;
await act(() => {
const {pipe} = renderToPipeableStream(
}>
,
{
onAllReady() {
allReadyCount++;
},
},
);
pipe(writable);
});
expect(allReadyCount).toBe(1);
expect(getVisibleChildren(container)).toEqual('hello');
});
// Same bug, hit without any sync-thenable trickery: if the fallback
// also suspends, its spawned sub-task lives in fallbackAbortableTasks
// and can still be there when the content task completes first.
it('only fires onAllReady once when both content and fallback suspend on real promises', async () => {
let resolveContent;
const contentPromise = new Promise(r => (resolveContent = r));
// The fallback promise never resolves — the fallback-sub-task gets
// soft-aborted when the content completes, so we never need it.
const fallbackPromise = new Promise(() => {});
function AsyncContent() {
return ;
}
function AsyncFallback() {
return ;
}
let allReadyCount = 0;
await act(() => {
const {pipe} = renderToPipeableStream(
}>
,
{
onAllReady() {
allReadyCount++;
},
},
);
pipe(writable);
});
// Resolving content alone is enough: the fallback-sub-task is still
// in fallbackAbortableTasks when the content task completes, and
// abortTaskSoft on it reenters finishedTask.
await act(async () => {
resolveContent('hello');
await contentPromise;
});
expect(allReadyCount).toBe(1);
expect(getVisibleChildren(container)).toEqual('hello');
});
it('promise as node', async () => {
const promise = Promise.resolve('Hi');
await act(async () => {
const {pipe} = renderToPipeableStream(promise);
pipe(writable);
});
// TODO: The `act` implementation in this file doesn't unwrap microtasks
// automatically. We can't use the same `act` we use for Fiber tests
// because that relies on the mock Scheduler. Doesn't affect any public
// API but we might want to fix this for our own internal tests.
await act(async () => {
await promise;
});
expect(getVisibleChildren(container)).toEqual('Hi');
});
it('context as node', async () => {
const Context = React.createContext('Hi');
await act(async () => {
const {pipe} = renderToPipeableStream(Context);
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual('Hi');
});
it('recursive Usable as node', async () => {
const Context = React.createContext('Hi');
const promiseForContext = Promise.resolve(Context);
await act(async () => {
const {pipe} = renderToPipeableStream(promiseForContext);
pipe(writable);
});
// TODO: The `act` implementation in this file doesn't unwrap microtasks
// automatically. We can't use the same `act` we use for Fiber tests
// because that relies on the mock Scheduler. Doesn't affect any public
// API but we might want to fix this for our own internal tests.
await act(async () => {
await promiseForContext;
});
expect(getVisibleChildren(container)).toEqual('Hi');
});
it('should correctly handle different promises in React.use() across lazy components', async () => {
let promise1;
let promise2;
let promiseLazy;
function Component1() {
promise1 ??= new Promise(r => setTimeout(() => r('value1'), 50));
const data = React.use(promise1);
return (
{data}
);
}
function Component2() {
promise2 ??= new Promise(r => setTimeout(() => r('value2'), 50));
const data = React.use(promise2);
return
,
);
});
it('useActionState hydrates without a mismatch', async () => {
// This is testing an implementation detail: useActionState emits comment
// nodes into the SSR stream, so this checks that they are handled correctly
// during hydration.
async function action(state) {
return state;
}
const childRef = React.createRef(null);
function Form() {
const [state] = useActionState(action, 0);
const text = `Child: ${state}`;
return (
,
);
const child = document.getElementById('child');
// Confirm that it hydrates correctly
await clientAct(() => {
ReactDOMClient.hydrateRoot(container, );
});
expect(childRef.current).toBe(child);
});
it("useActionState hydrates without a mismatch if there's a render phase update", async () => {
async function action(state) {
return state;
}
const childRef = React.createRef(null);
function Form() {
const [localState, setLocalState] = React.useState(0);
if (localState < 3) {
setLocalState(localState + 1);
}
// Because of the render phase update above, this component is evaluated
// multiple times (even during SSR), but it should only emit a single
// marker per useActionState instance.
const [actionState] = useActionState(action, 0);
const text = `${readText('Child')}:${actionState}:${localState}`;
return (
,
);
const child = document.getElementById('child');
// Confirm that it hydrates correctly
await clientAct(() => {
ReactDOMClient.hydrateRoot(container, );
});
expect(childRef.current).toBe(child);
});
describe('useEffectEvent', () => {
it('can server render a component with useEffectEvent', async () => {
const ref = React.createRef();
function App() {
const [count, setCount] = React.useState(0);
const onClick = React.useEffectEvent(() => {
setCount(c => c + 1);
});
return (
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual();
ReactDOMClient.hydrateRoot(container, );
await waitForAll([]);
expect(getVisibleChildren(container)).toEqual();
ref.current.dispatchEvent(
new window.MouseEvent('click', {bubbles: true}),
);
await jest.runAllTimers();
expect(getVisibleChildren(container)).toEqual();
});
it('throws if useEffectEvent is called during a server render', async () => {
const logs = [];
function App() {
const onRender = React.useEffectEvent(() => {
logs.push('rendered');
});
onRender();
return
Hello
;
}
const reportedServerErrors = [];
let caughtError;
try {
await act(() => {
const {pipe} = renderToPipeableStream(, {
onError(e) {
reportedServerErrors.push(e);
},
});
pipe(writable);
});
} catch (err) {
caughtError = err;
}
expect(logs).toEqual([]);
expect(caughtError.message).toContain(
"A function wrapped in useEffectEvent can't be called during rendering.",
);
expect(reportedServerErrors).toEqual([caughtError]);
});
it('does not guarantee useEffectEvent return values during server rendering are distinct', async () => {
function App() {
const onClick1 = React.useEffectEvent(() => {});
const onClick2 = React.useEffectEvent(() => {});
if (onClick1 === onClick2) {
return ;
} else {
return ;
}
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual();
const errors = [];
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
errors.push(error);
},
});
await waitForAll([]);
expect(errors.length).toEqual(1);
expect(getVisibleChildren(container)).toEqual();
});
});
it('can render scripts with simple children', async () => {
await act(async () => {
const {pipe} = renderToPipeableStream(
,
);
pipe(writable);
});
expect(document.documentElement.outerHTML).toEqual(
'' +
(gate(flags => flags.shouldUseFizzExternalRuntime)
? ''
: '') +
(gate(flags => flags.enableFizzBlockingRender)
? ''
: '') +
'' +
(gate(flags => flags.enableFizzBlockingRender)
? ''
: '') +
'',
);
});
it('warns if script has complex children', async () => {
function MyScript() {
return 'bar();';
}
function App() {
return (
);
}
await act(async () => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
assertConsoleErrorDev([
'A script element was rendered with a number for children. If script element has children it must be a single string. Consider using dangerouslySetInnerHTML or passing a plain string as children.' +
componentStack(['script', 'App']),
'A script element was rendered with an array for children. If script element has children it must be a single string. Consider using dangerouslySetInnerHTML or passing a plain string as children.' +
componentStack(['script', 'App']),
'A script element was rendered with something unexpected for children. If script element has children it must be a single string. Consider using dangerouslySetInnerHTML or passing a plain string as children.' +
componentStack(['script', 'App']),
]);
});
it(
'a transition that flows into a dehydrated boundary should not suspend ' +
'if the boundary is showing a fallback',
async () => {
let setSearch;
function App() {
const [search, _setSearch] = React.useState('initial query');
setSearch = _setSearch;
return (
{search}
);
}
// Render the initial HTML, which is showing a fallback.
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
// Start hydrating.
await clientAct(() => {
ReactDOMClient.hydrateRoot(container, );
});
expect(getVisibleChildren(container)).toEqual(
initial query
Loading...
,
);
// Before the HTML has streamed in, update the query. The part outside
// the fallback should be allowed to finish.
await clientAct(() => {
React.startTransition(() => setSearch('updated query'));
});
expect(getVisibleChildren(container)).toEqual(
updated query
Loading...
,
);
},
);
it('can resume a prerender that was aborted', async () => {
const promise = new Promise(r => {});
let prerendering = true;
function Wait() {
if (prerendering) {
return React.use(promise);
} else {
return 'Hello';
}
}
function App() {
return (
;
}
let finished = false;
await act(() => {
const {pipe, abort} = renderToPipeableStream();
abortRef.current = abort;
writable.on('finish', () => {
finished = true;
});
pipe(writable);
});
assertConsoleErrorDev([
'Error: The render was aborted by the server without a reason.' +
'\n in ',
'Error: The render was aborted by the server without a reason.' +
'\n in ',
'Error: The render was aborted by the server without a reason.' +
'\n in ',
]);
expect(finished).toBe(true);
expect(getVisibleChildren(container)).toEqual(
loading 1...
loading 2...
loading 3...
,
);
});
it('reports an in-flight root task after another root task fatals while aborting', async () => {
const promise = new Promise(() => {});
function SuspendedRoot() {
use(promise);
return null;
}
function Child() {
return 'child';
}
const abortRef = {current: null};
function ComponentThatAborts() {
abortRef.current(new Error('abort reason'));
return ;
}
const errors = [];
await act(() => {
const {abort} = renderToPipeableStream(
<>
>,
{
onError(error) {
errors.push(error.message);
},
onShellError() {},
},
);
abortRef.current = abort;
});
expect(errors).toEqual(['abort reason', 'abort reason']);
});
// @gate enableBrowserAPI
it('reports an in-flight browser bailout after another root task fatals while aborting', async () => {
const promise = new Promise(() => {});
function SuspendedRoot() {
use(promise);
return null;
}
function Child() {
return 'child';
}
const browserValue = ReactDOM.browser('abort reason');
const abortRef = {current: null};
function ComponentThatAborts() {
abortRef.current(browserValue);
return ;
}
const errors = [];
const browserBailouts = [];
let shellError;
await act(() => {
const {abort} = renderToPipeableStream(
<>
>,
{
onError(error) {
errors.push(error);
},
onBrowserBailout(error) {
browserBailouts.push(error);
},
onShellError(error) {
shellError = error;
},
},
);
abortRef.current = abort;
});
expect(errors).toEqual([shellError]);
expect(browserBailouts).toHaveLength(1);
expect(browserBailouts[0]).not.toBe(shellError);
expect(browserBailouts[0].message).toBe(
'Browser-only rendering was requested by `browser()`.',
);
expect(browserBailouts[0].cause).toBe('abort reason');
});
it('reports a root task before rendering a suspended child returned after aborting', async () => {
const promise = new Promise(() => {});
function SuspendedRoot() {
use(promise);
return null;
}
function Child() {
use(promise);
return null;
}
const abortRef = {current: null};
function ComponentThatAborts() {
abortRef.current(new Error('abort reason'));
return ;
}
const errors = [];
await act(() => {
const {abort} = renderToPipeableStream(
<>
>,
{
onError(error) {
errors.push(error.message);
},
onShellError() {},
},
);
abortRef.current = abort;
});
expect(errors).toEqual(['abort reason', 'abort reason']);
});
it('reports a root task that suspends directly after aborting during render', async () => {
const promise = new Promise(() => {});
const abortRef = {current: null};
function ComponentThatAbortsAndSuspends() {
abortRef.current(new Error('abort reason'));
use(promise);
return null;
}
const errors = [];
await act(() => {
const {abort} = renderToPipeableStream(
,
{
onError(error) {
errors.push(error.message);
},
onShellError() {},
},
);
abortRef.current = abort;
});
expect(errors).toEqual(['abort reason']);
});
it('can abort during render in a lazy initializer for a component', async () => {
function Sibling() {
return
sibling
;
}
function App() {
return (
loading 1...}>
loading 2...}>
loading 3...}>
);
}
const abortRef = {current: null};
const LazyAbort = React.lazy(() => {
abortRef.current();
return {
then(cb) {
cb({default: 'div'});
},
};
});
let finished = false;
await act(() => {
const {pipe, abort} = renderToPipeableStream();
abortRef.current = abort;
writable.on('finish', () => {
finished = true;
});
pipe(writable);
});
assertConsoleErrorDev([
'Error: The render was aborted by the server without a reason.' +
'\n in ',
'Error: The render was aborted by the server without a reason.' +
'\n in ',
'Error: The render was aborted by the server without a reason.' +
'\n in ',
]);
expect(finished).toBe(true);
expect(getVisibleChildren(container)).toEqual(
loading 1...
loading 2...
loading 3...
,
);
});
it('can abort during render in a lazy initializer for an element', async () => {
function Sibling() {
return
sibling
;
}
function App() {
return (
loading 1...}>
{lazyAbort}
loading 2...}>
loading 3...}>
);
}
const abortRef = {current: null};
const lazyAbort = React.lazy(() => {
abortRef.current();
return {
then(cb) {
cb({default: 'hello world'});
},
};
});
let finished = false;
await act(() => {
const {pipe, abort} = renderToPipeableStream();
abortRef.current = abort;
writable.on('finish', () => {
finished = true;
});
pipe(writable);
});
assertConsoleErrorDev([
'Error: The render was aborted by the server without a reason.' +
'\n in ',
'Error: The render was aborted by the server without a reason.' +
'\n in ',
'Error: The render was aborted by the server without a reason.' +
'\n in ',
]);
expect(finished).toBe(true);
expect(getVisibleChildren(container)).toEqual(
loading 1...
loading 2...
loading 3...
,
);
});
it('can abort during a synchronous thenable resolution', async () => {
function Sibling() {
return
sibling
;
}
function App() {
return (
loading 1...}>
{thenable}
loading 2...}>
loading 3...}>
);
}
const abortRef = {current: null};
const thenable = {
then(cb) {
abortRef.current();
cb(thenable.value);
},
};
let finished = false;
await act(() => {
const {pipe, abort} = renderToPipeableStream();
abortRef.current = abort;
writable.on('finish', () => {
finished = true;
});
pipe(writable);
});
assertConsoleErrorDev([
'Error: The render was aborted by the server without a reason.' +
'\n in ',
'Error: The render was aborted by the server without a reason.' +
'\n in ',
'Error: The render was aborted by the server without a reason.' +
'\n in ',
]);
expect(finished).toBe(true);
expect(getVisibleChildren(container)).toEqual(
loading 1...
loading 2...
loading 3...
,
);
});
it('can support throwing after aborting during a render', async () => {
function App() {
return (
,
);
});
it('should warn for using generators as children props', async () => {
function* getChildren() {
yield
Hello
;
yield
World
;
}
function Foo() {
const children = getChildren();
return
{children}
;
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
assertConsoleErrorDev([
'Using Iterators as children is unsupported and will likely yield ' +
'unexpected results because enumerating a generator mutates it. ' +
'You may convert it to an array with `Array.from()` or the ' +
'`[...spread]` operator before rendering. You can also use an ' +
'Iterable that can iterate multiple times over the same items.\n' +
' in div (at **)\n' +
' in Foo (at **)',
]);
expect(document.body.textContent).toBe('HelloWorld');
});
it('should warn for using other types of iterators as children', async () => {
function Foo() {
let i = 0;
const iterator = {
[Symbol.iterator]() {
return iterator;
},
next() {
switch (i++) {
case 0:
return {done: false, value:
Hello
};
case 1:
return {done: false, value:
World
};
default:
return {done: true, value: undefined};
}
},
};
return iterator;
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
assertConsoleErrorDev([
'Using Iterators as children is unsupported and will likely yield ' +
'unexpected results because enumerating a generator mutates it. ' +
'You may convert it to an array with `Array.from()` or the ' +
'`[...spread]` operator before rendering. You can also use an ' +
'Iterable that can iterate multiple times over the same items.\n' +
' in Foo (at **)',
]);
expect(document.body.textContent).toBe('HelloWorld');
});
// @gate __DEV__
it('can get the component owner stacks during rendering in dev', async () => {
let stack;
function Foo() {
return ;
}
function Bar() {
return (
,
);
pipe(writable);
});
expect(normalizeCodeLocInfo(stack)).toBe(
'\n in Bar (at **)' + '\n in Foo (at **)',
);
});
// @gate __DEV__
it('can get the component owner stacks for onError in dev', async () => {
const thrownError = new Error('hi');
let caughtError;
let parentStack;
let ownerStack;
function Foo() {
return ;
}
function Bar() {
return (
,
{
onError(error, errorInfo) {
caughtError = error;
parentStack = errorInfo.componentStack;
ownerStack = React.captureOwnerStack
? React.captureOwnerStack()
: null;
},
},
);
pipe(writable);
});
}).rejects.toThrow(thrownError);
expect(caughtError).toBe(thrownError);
expect(normalizeCodeLocInfo(parentStack)).toBe(
'\n in Baz (at **)' +
'\n in div (at **)' +
'\n in Bar (at **)' +
'\n in Foo (at **)' +
'\n in div (at **)',
);
expect(normalizeCodeLocInfo(ownerStack)).toBe(
'\n in Bar (at **)' + '\n in Foo (at **)',
);
});
it('can recover from very deep trees to avoid stack overflow', async () => {
function Recursive({n}) {
if (n > 0) {
return ;
}
return hi;
}
// Recursively render a component tree deep enough to trigger stack overflow
// more than once. The first overflow is recovered by the renderNode
// trampoline; deeper trees must also recover when the retried task
// overflows again. Don't make this too deep to slow down the test.
await act(() => {
const {pipe} = renderToPipeableStream(
,
);
});
it('handles stack overflows inside components themselves', async () => {
function StackOverflow() {
// This component is recursive inside itself and is therefore an error.
// Assuming no tail-call optimizations.
function recursive(n, a0, a1, a2, a3) {
if (n > 0) {
return recursive(n - 1, a0, a1, a2, a3) + a0 + a1 + a2 + a3;
}
return a0;
}
return recursive(10000, 'should', 'not', 'resolve', 'this');
}
let caughtError;
await expect(async () => {
await act(() => {
const {pipe} = renderToPipeableStream(
,
{
onError(error, errorInfo) {
caughtError = error;
},
},
);
pipe(writable);
});
}).rejects.toThrow('Maximum call stack size exceeded');
expect(caughtError.message).toBe('Maximum call stack size exceeded');
});
it('can recover from very deep trees during resume to avoid stack overflow', async () => {
const promise = new Promise(() => {});
let prerendering = true;
// Deep wrappers above the postponed boundary. On resume, replaying this
// path goes through retryReplayTask → retryNode (no trampoline), so a
// tree deep enough to overflow must recover there — not only on the
// ordinary render retry path.
function Deep({n, children}) {
if (n > 0) {
return {children};
}
return children;
}
function Content() {
if (prerendering) {
return React.use(promise);
}
return hi;
}
function App() {
return (
,
);
await act(() => {
// We now end the stream and resolve the promise that was blocking the boundary
// Because the stream is ended it won't actually propagate to the client
writable.end();
document.readyState = 'complete';
resolve();
});
// ending the stream early will cause it to error on the server
expect(errors).toEqual([
expect.stringContaining('The destination stream closed early'),
]);
expect(getVisibleChildren(container)).toEqual(
outside
loading...
,
);
const clientErrors = [];
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error, errorInfo) {
clientErrors.push(error.message);
},
});
await waitForAll([]);
// When we hydrate the client the document is already not loading
// so we client render the boundary in fallback
expect(getVisibleChildren(container)).toEqual(
outside
inside
,
);
expect(clientErrors).toEqual([
expect.stringContaining(
'The server could not finish this Suspense boundar',
),
]);
});
it('client renders incomplete Suspense boundaries when the document stops loading during hydration', async () => {
let resolve;
const promise = new Promise(r => {
resolve = r;
});
function Blocking() {
React.use(promise);
return null;
}
function App() {
return (
,
);
await act(() => {
// We now end the stream and resolve the promise that was blocking the boundary
// Because the stream is ended it won't actually propagate to the client
writable.end();
resolve();
});
// ending the stream early will cause it to error on the server
expect(errors).toEqual([
expect.stringContaining('The destination stream closed early'),
]);
expect(getVisibleChildren(container)).toEqual(
outside
loading...
,
);
const clientErrors = [];
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error, errorInfo) {
clientErrors.push(error.message);
},
});
await waitForAll([]);
// When we hydrate the client is still waiting for the blocked boundary
// and won't client render unless the document is no longer loading
expect(getVisibleChildren(container)).toEqual(
outside
loading...
,
);
document.readyState = 'complete';
await waitForAll([]);
// Now that the document is no longer in loading readyState it will client
// render the boundary in fallback
expect(getVisibleChildren(container)).toEqual(
outside
inside
,
);
expect(clientErrors).toEqual([
expect.stringContaining(
'The server could not finish this Suspense boundar',
),
]);
});
it('can suspend inside the tag', async () => {
function BlockedOn({value, children}) {
readText(value);
return children;
}
function App() {
return (
}>
,
);
await act(() => {
root.unmount();
});
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
,
);
});
it('can render Suspense before, after, and around ', async () => {
function BlockedOn({value, children}) {
readText(value);
return children;
}
function App() {
return (
<>
before
hello world
after
>
);
}
let content = '';
writable.on('data', chunk => (content += chunk));
let shellReady = false;
await act(() => {
const {pipe} = renderToPipeableStream(, {
onShellReady: () => {
shellReady = true;
},
});
pipe(writable);
});
// When we Suspend above the body we block the shell because the root HTML scope
// is considered "reconciliation" mode whereby we should stay on the prior view
// (the prior page for instance) rather than showing the fallback (semantically)
expect(shellReady).toBe(true);
expect(content).toBe('');
await act(() => {
resolveText('html');
});
expect(content).toMatch(/^/);
expect(getVisibleChildren(document)).toEqual(
,
);
assertConsoleErrorDev([
'Cannot render a outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this remove the `itemProp` prop. Otherwise, try moving this tag into the or of the Document.',
'In HTML, cannot be a child of .\nThis will cause a hydration error.' +
'\n' +
'\n ' +
'\n> ' +
'\n ' +
'\n ' +
'\n> ' +
'\n ...' +
'\n' +
'\n in meta (at **)' +
'\n in App (at **)',
' cannot contain a nested .\nSee this log for the ancestor stack trace.' +
'\n in html (at **)' +
'\n in App (at **)',
'Cannot render a outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this remove the `itemProp` prop. Otherwise, try moving this tag into the or of the Document.',
]);
await root.unmount();
expect(getVisibleChildren(document)).toEqual(
,
);
});
it('can render Suspense before, after, and around ', async () => {
function BlockedOn({value, children}) {
readText(value);
return children;
}
function App() {
return (
,
);
assertConsoleErrorDev([
'Cannot render a outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this remove the `itemProp` prop. Otherwise, try moving this tag into the or of the Document.',
'In HTML, cannot be a child of .\nThis will cause a hydration error.' +
'\n' +
'\n ' +
'\n> ' +
'\n ' +
'\n ' +
'\n> ' +
'\n ...' +
'\n' +
'\n in meta (at **)' +
'\n in App (at **)',
' cannot contain a nested .\nSee this log for the ancestor stack trace.' +
'\n in html (at **)' +
'\n in App (at **)',
'Cannot render a outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this remove the `itemProp` prop. Otherwise, try moving this tag into the or of the Document.',
]);
await root.unmount();
expect(getVisibleChildren(document)).toEqual(
,
);
});
it('will render fallback Document when erroring a boundary above the body and recover on the client', async () => {
let serverRendering = true;
function Boom() {
if (serverRendering) {
throw new Error('Boom!');
}
return null;
}
function App() {
return (
hello error
}>
hello world
);
}
let content = '';
writable.on('data', chunk => (content += chunk));
let shellReady = false;
const errors = [];
await act(() => {
const {pipe} = renderToPipeableStream(, {
onShellReady() {
shellReady = true;
},
onError(e) {
errors.push(e);
},
});
pipe(writable);
});
expect(shellReady).toBe(true);
expect(content).toMatch(/^/);
expect(errors).toEqual([new Error('Boom!')]);
expect(getVisibleChildren(document)).toEqual(
hello error
,
);
serverRendering = false;
const recoverableErrors = [];
const root = ReactDOMClient.hydrateRoot(document, , {
onRecoverableError(err) {
recoverableErrors.push(err);
},
});
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
hello world
,
);
expect(recoverableErrors).toEqual([
__DEV__
? new Error(
'Switched to client rendering because the server rendering errored:\n\nBoom!',
)
: new Error(
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
),
]);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
,
);
});
it('will hoist resources and hositables from a primary tree into the of a client rendered fallback', async () => {
let serverRendering = true;
function Boom() {
if (serverRendering) {
throw new Error('Boom!');
}
return null;
}
function App() {
return (
<>
{/* we have to make this a non-hoistable because we don't current emit
hoistables inside fallbacks because we have no way to clean them up
on hydration */}
hello error
}>
hello world
>
);
}
let content = '';
writable.on('data', chunk => (content += chunk));
let shellReady = false;
const errors = [];
await act(() => {
const {pipe} = renderToPipeableStream(, {
onShellReady() {
shellReady = true;
},
onError(e) {
errors.push(e);
},
});
pipe(writable);
});
expect(shellReady).toBe(true);
expect(content).toMatch(/^/);
expect(errors).toEqual([new Error('Boom!')]);
expect(getVisibleChildren(document)).toEqual(
hello error
,
);
serverRendering = false;
const recoverableErrors = [];
const root = ReactDOMClient.hydrateRoot(document, , {
onRecoverableError(err) {
recoverableErrors.push(err);
},
});
await waitForAll([]);
expect(recoverableErrors).toEqual([
__DEV__
? new Error(
'Switched to client rendering because the server rendering errored:\n\nBoom!',
)
: new Error(
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
),
]);
expect(getVisibleChildren(document)).toEqual(
hello world
,
);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
,
);
});
it('will attempt to render the preamble inline to allow rendering before a later abort in the same task', async () => {
const promise = new Promise(() => {});
function Pending() {
React.use(promise);
}
const controller = new AbortController();
function Abort() {
controller.abort();
return ;
}
function Comp() {
return null;
}
function App() {
return (
hello
);
}
const signal = controller.signal;
let thrownError = null;
const errors = [];
try {
await act(() => {
const {pipe, abort} = renderToPipeableStream(, {
onError(e, ei) {
errors.push({
error: e,
componentStack: normalizeCodeLocInfo(ei.componentStack),
});
},
});
signal.addEventListener('abort', () => abort('boom'));
pipe(writable);
});
} catch (e) {
thrownError = e;
}
expect(thrownError).toBe('boom');
expect(errors).toEqual([
{
error: 'boom',
componentStack: componentStack(['Abort', 'body', 'html', 'App']),
},
{
error: 'boom',
componentStack: componentStack([
'Pending',
'Suspense',
'body',
'html',
'App',
]),
},
{
error: 'boom',
componentStack: componentStack([
'Suspense Fallback',
'body',
'html',
'App',
]),
},
]);
// We expect the render to throw before streaming anything so the default
// document is still loaded
expect(getVisibleChildren(document)).toEqual(
,
);
});
it('Will wait to flush Document chunks until all boundaries which might contain a preamble are errored or resolved', async () => {
let rejectFirst;
const firstPromise = new Promise((_, reject) => {
rejectFirst = reject;
});
function First({children}) {
use(firstPromise);
return children;
}
let resolveSecond;
const secondPromise = new Promise(resolve => {
resolveSecond = resolve;
});
function Second({children}) {
use(secondPromise);
return children;
}
const hangingPromise = new Promise(() => {});
function Hanging({children}) {
use(hangingPromise);
return children;
}
function App() {
return (
<>
loading...}>
inner loading...}>
firstloading...}>
second
,
);
serverRendering = false;
const recoverableErrors = [];
const root = ReactDOMClient.hydrateRoot(document, , {
onRecoverableError(err) {
recoverableErrors.push(err);
},
});
await waitForAll([]);
expect(recoverableErrors).toEqual([
__DEV__
? new Error(
'Switched to client rendering because the server rendering errored:\n\nBoom!',
)
: new Error(
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
),
]);
expect(getVisibleChildren(document)).toEqual(
primary body
,
);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
,
);
});
it('Can render a fallback alongside a non-fallback head', async () => {
let serverRendering = true;
function Boom() {
if (serverRendering) {
throw new Error('Boom!');
}
return null;
}
function App() {
return (
}>
,
);
expect(recoverableErrors).toEqual([
__DEV__
? new Error(
'Switched to client rendering because the server rendering errored:\n\nBoom!',
)
: new Error(
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
),
]);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
,
);
});
it('Can render a outside of a containing ', async () => {
function App() {
return (
<>
hello world
>
);
}
let content = '';
writable.on('data', chunk => (content += chunk));
let shellReady = false;
await act(() => {
const {pipe} = renderToPipeableStream(, {
onShellReady() {
shellReady = true;
},
});
pipe(writable);
});
expect(shellReady).toBe(true);
expect(content).toMatch(/^/);
expect(getVisibleChildren(document)).toEqual(
hello world
,
);
const root = ReactDOMClient.hydrateRoot(document, );
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
hello world
,
);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
{metadata.map(m => (
))}
);
}
let loadMainContent;
const mainContentPromise = new Promise(r => {
loadMainContent = r;
});
function Main() {
return (
}>
);
}
function Skeleton() {
return (
,
);
});
it('will flush the preamble as soon as a complete preamble is available', async () => {
function BlockedOn({value, children}) {
readText(value);
return children;
}
function App() {
return (
<>
,
);
});
it('will clean up the head when a hydration mismatch causes a boundary to recover on the client', async () => {
let content = 'server';
function ServerApp() {
return (
{content}
);
}
function ClientApp() {
return (
{content}
);
}
await act(() => {
const {pipe} = renderToPipeableStream();
pipe(writable);
});
expect(getVisibleChildren(document)).toEqual(
server
,
);
content = 'client';
const recoverableErrors = [];
const root = ReactDOMClient.hydrateRoot(document, , {
onRecoverableError(err) {
recoverableErrors.push(err.message);
},
});
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
client
,
);
expect(recoverableErrors).toEqual([
expect.stringContaining(
"Hydration failed because the server rendered text didn't match the client.",
),
]);
root.unmount();
expect(getVisibleChildren(document)).toEqual(
,
);
});
it("shouldn't render styles with mismatched nonce", async () => {
CSPnonce = 'R4nd0m';
await act(() => {
const {pipe} = renderToPipeableStream(
<>
>,
{nonce: {style: CSPnonce}},
);
pipe(writable);
});
assertConsoleErrorDev([
'React encountered a style tag with `precedence` "default" and `nonce` "R4nd0mR4nd0m". When React manages style rules using `precedence` it will only include rules if the nonce matches the style nonce "R4nd0m" that was included with this render.' +
'\n in style (at **)',
]);
expect(getVisibleChildren(document)).toEqual(
,
);
});
it("should render styles without nonce when render call doesn't receive nonce", async () => {
await act(() => {
const {pipe} = renderToPipeableStream(
<>
>,
);
pipe(writable);
});
assertConsoleErrorDev([
'React encountered a style tag with `precedence` "default" and `nonce` "R4nd0m". When React manages style rules using `precedence` it will only include a nonce attributes if you also provide the same style nonce value as a render option.' +
'\n in style (at **)',
]);
expect(getVisibleChildren(document)).toEqual(
,
);
});
it('should render styles without nonce when render call receives a string nonce dedicated to scripts', async () => {
CSPnonce = 'R4nd0m';
await act(() => {
const {pipe} = renderToPipeableStream(
<>
>,
{nonce: CSPnonce},
);
pipe(writable);
});
assertConsoleErrorDev([
'React encountered a style tag with `precedence` "default" and `nonce` "R4nd0m". When React manages style rules using `precedence` it will only include a nonce attributes if you also provide the same style nonce value as a render option.' +
'\n in style (at **)',
]);
expect(getVisibleChildren(document)).toEqual(
);
});
it('should always flush the boundaries contributing the preamble regardless of their size', async () => {
const longDescription =
`I need to make this segment somewhat large because it needs to be large enough to be outlined during the initial flush. Setting the progressive chunk size to near zero isn't enough because there is a fixed minimum size that we use to avoid doing the size tracking altogether and this needs to be larger than that at least.
Unfortunately that previous paragraph wasn't quite long enough so I'll continue with some more prose and maybe throw on some repeated additional strings at the end for good measure.
` + 'a'.repeat(500);
const randomTag = Math.random().toString(36).slice(2, 10);
function App() {
return (
{longDescription}
);
}
let streamedContent = '';
writable.on('data', chunk => (streamedContent += chunk));
await act(() => {
renderToPipeableStream(, {progressiveChunkSize: 100}).pipe(
writable,
);
});
// We don't use the DOM here b/c we execute scripts which hides whether a fallback was shown briefly
// Instead we assert that we never emitted the fallback of the Suspense boundary around the body.
expect(streamedContent).not.toContain(randomTag);
});
it('should track byte size of shells that may contribute to the preamble when determining if the blocking render exceeds the max size', async () => {
const longDescription =
`I need to make this segment somewhat large because it needs to be large enough to be outlined during the initial flush. Setting the progressive chunk size to near zero isn't enough because there is a fixed minimum size that we use to avoid doing the size tracking altogether and this needs to be larger than that at least.
Unfortunately that previous paragraph wasn't quite long enough so I'll continue with some more prose and maybe throw on some repeated additional strings at the end for good measure.
` + 'a'.repeat(500);
const randomTag = Math.random().toString(36).slice(2, 10);
function App() {
return (
<>
{longDescription}
Outside Preamble
>
);
}
let streamedContent = '';
writable.on('data', chunk => (streamedContent += chunk));
const errors = [];
await act(() => {
renderToPipeableStream(, {
progressiveChunkSize: 5,
onError(e) {
errors.push(e);
},
}).pipe(writable);
});
if (gate(flags => flags.enableFizzBlockingRender)) {
expect(errors.length).toBe(1);
expect(errors[0].message).toContain(
// We set the chunk size low enough that the threshold rounds to zero kB
'This rendered a large document (>0 kB) without any Suspense boundaries around most of it.',
);
} else {
expect(errors.length).toBe(0);
}
// We don't use the DOM here b/c we execute scripts which hides whether a fallback was shown briefly
// Instead we assert that we never emitted the fallback of the Suspense boundary around the body.
expect(streamedContent).not.toContain(randomTag);
});
it('should be able to Suspend after aborting in the same component without hanging the render', async () => {
const controller = new AbortController();
const promise1 = new Promise(() => {});
function AbortAndSuspend() {
controller.abort('boom');
return React.use(promise1);
}
function App() {
return (
{/*
The particular code path that was problematic required the Suspend to happen in renderNode
rather than retryRenderTask so we render the aborting function inside a host component
intentionally here
*/}
,
);
});
it('outlines boundaries based on UTF-8 byte size, not code unit count', async () => {
// Boundaries are outlined when byteSize > 500, which streams the fallback
// first. Content is 200 three-byte characters: 600 UTF-8 bytes but only 200
// code units. The fallback should be shown initially because the boundary is
// large enough to outline. A string.length shortcut for byte size would
// count 200, stay under the threshold, and inline the content with no
// fallback shown — which would be incorrect.
const multiByte = '✓'.repeat(200);
function App() {
return (
{multiByte}
);
}
await act(async () => {
renderToPipeableStream(, {progressiveChunkSize: 100}).pipe(
writable,
);
await jest.runAllTimers();
const temp = document.createElement('body');
temp.innerHTML = buffer;
// Fallback is shown because the boundary is outlined by its UTF-8 size.
expect(getVisibleChildren(temp)).toEqual(