/** * 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 (
Fallback}>
); } const serverErrors = []; const browserBailouts = []; await act(() => { const {pipe} = renderToPipeableStream(, { onError(error) { serverErrors.push(error); }, onBrowserBailout(error, errorInfo) { browserBailouts.push({error, errorInfo}); }, }); pipe(writable); }); expect(serverErrors).toEqual([]); expect(initializeReason).toHaveBeenCalledTimes(1); expect(browserBailouts).toHaveLength(1); expect(browserBailouts[0].error).toBeInstanceOf(Error); expect(browserBailouts[0].error.message).toBe( 'Browser-only rendering was requested by `browser()`.', ); expect(browserBailouts[0].error.stack).toContain('BrowserOnly'); expect(browserBailouts[0].error.cause).toBe(browserReason); expect( normalizeCodeLocInfo(browserBailouts[0].errorInfo.componentStack), ).toBe(componentStack(['BrowserOnly', 'Suspense', 'div', 'App'])); expect(getVisibleChildren(container)).toEqual(
Fallback
, ); const recoverableErrors = []; ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { recoverableErrors.push(error); }, }); await waitForAll([]); expect(getVisibleChildren(container)).toEqual(
Fallback
, ); await clientAct(() => { resolveBrowserText('Browser'); }); assertLog(['Browser']); expect(recoverableErrors).toEqual([]); expect(initializeReason).toHaveBeenCalledTimes(1); expect(getVisibleChildren(container)).toEqual(
Browser
, ); }); // @gate enableBrowserAPI it('can opt a component into browser-only rendering after streaming the fallback', async () => { let resolveServerReady; const serverReady = new Promise(resolve => { resolveServerReady = resolve; }); const initializeReason = jest.fn( () => 'Only render this content in a browser', ); function BrowserOnly() { use(serverReady); use(ReactDOM.browser(initializeReason)); return Browser; } function App() { return (
Fallback}>
); } const serverErrors = []; const browserBailouts = []; await act(() => { const {pipe} = renderToPipeableStream(, { onError(error) { serverErrors.push(error); }, onBrowserBailout(error) { browserBailouts.push(error); }, }); pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
Fallback
, ); await act(() => { resolveServerReady(); }); expect(serverErrors).toEqual([]); expect(initializeReason).toHaveBeenCalledTimes(1); expect(browserBailouts).toHaveLength(1); expect(browserBailouts[0].message).toBe( 'Browser-only rendering was requested by `browser()`.', ); expect(browserBailouts[0].stack).toContain('BrowserOnly'); expect(browserBailouts[0].cause).toBe( 'Only render this content in a browser', ); const recoverableErrors = []; ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { recoverableErrors.push(error); }, }); await waitForAll([]); expect(recoverableErrors).toEqual([]); expect(initializeReason).toHaveBeenCalledTimes(1); expect(getVisibleChildren(container)).toEqual(
Browser
, ); }); // @gate enableBrowserAPI it('supports omitted and direct string browser reasons', async () => { const directReason = 'Only render this content in a browser'; const withoutReason = ReactDOM.browser(); const withDirectReason = ReactDOM.browser(directReason); function WithoutReason() { use(withoutReason); return Browser; } function WithDirectReason() { use(withDirectReason); return Browser; } const serverErrors = []; const browserBailouts = []; await act(() => { const {pipe} = renderToPipeableStream( <> Fallback A}> Fallback B}> , { onError(error) { serverErrors.push(error); }, onBrowserBailout(error) { browserBailouts.push(error); }, }, ); pipe(writable); }); expect(serverErrors).toEqual([]); expect(browserBailouts).toHaveLength(2); expect(browserBailouts[0].message).toBe( 'Browser-only rendering was requested by `browser()`.', ); expect(browserBailouts[0].stack).toContain('WithoutReason'); expect( Object.prototype.hasOwnProperty.call(browserBailouts[0], 'cause'), ).toBe(false); expect(browserBailouts[1].message).toBe( 'Browser-only rendering was requested by `browser()`.', ); expect(browserBailouts[1].stack).toContain('WithDirectReason'); expect(browserBailouts[1].cause).toBe(directReason); }); // @gate enableBrowserAPI it('supports any value returned by a browser reason initializer', async () => { const reasonValues = [undefined, null, 42, Symbol('browser reason')]; const initializeReasons = reasonValues.map(reason => jest.fn(() => reason)); const browserValues = initializeReasons.map(initializeReason => ReactDOM.browser(initializeReason), ); function BrowserOnly({browserValue}) { use(browserValue); return Browser; } const serverErrors = []; const browserBailouts = []; await act(() => { const {pipe} = renderToPipeableStream( <> {browserValues.map((browserValue, index) => ( Fallback}> ))} , { onError(error) { serverErrors.push(error); }, onBrowserBailout(error) { browserBailouts.push(error); }, }, ); pipe(writable); }); expect(serverErrors).toEqual([]); expect(browserBailouts).toHaveLength(reasonValues.length); initializeReasons.forEach(initializeReason => { expect(initializeReason).toHaveBeenCalledTimes(1); }); browserBailouts.forEach((error, index) => { expect(error).toBeInstanceOf(Error); expect(error.message).toBe( 'Browser-only rendering was requested by `browser()`.', ); expect(Object.prototype.hasOwnProperty.call(error, 'cause')).toBe(true); expect(error.cause).toBe(reasonValues[index]); }); }); // @gate enableBrowserAPI it('initializes a shared browser reason at each use site', async () => { const browserReasons = []; const initializeReason = jest.fn(() => { const browserReason = {index: browserReasons.length}; browserReasons.push(browserReason); return browserReason; }); const browserValue = ReactDOM.browser(initializeReason); function BrowserOnlyA() { use(browserValue); return Browser A; } function BrowserOnlyB() { use(browserValue); return Browser B; } const browserBailouts = []; await act(() => { const {pipe} = renderToPipeableStream( <> Fallback A}> Fallback B}> , { onBrowserBailout(error) { browserBailouts.push(error); }, }, ); pipe(writable); }); expect(initializeReason).toHaveBeenCalledTimes(2); expect(browserBailouts).toHaveLength(2); expect(browserBailouts[0]).not.toBe(browserBailouts[1]); expect(browserBailouts[0].cause).toBe(browserReasons[0]); expect(browserBailouts[0].stack).toContain('BrowserOnlyA'); expect(browserBailouts[1].cause).toBe(browserReasons[1]); expect(browserBailouts[1].stack).toContain('BrowserOnlyB'); }); // @gate enableBrowserAPI it('uses a fallback if a browser reason initializer throws', async () => { const reasonError = new Error('Failed to initialize browser reason'); const initializeReason = jest.fn(() => { throw reasonError; }); const browserValue = ReactDOM.browser(initializeReason); function BrowserOnly() { use(browserValue); return Browser; } const serverErrors = []; const browserBailouts = []; await act(() => { const {pipe} = renderToPipeableStream( Fallback}> , { onError(error) { serverErrors.push(error); }, onBrowserBailout(error) { browserBailouts.push(error); }, }, ); pipe(writable); }); expect(initializeReason).toHaveBeenCalledTimes(1); expect(serverErrors).toEqual([]); expect(browserBailouts).toHaveLength(1); expect(browserBailouts[0].cause).toBe( 'The reason for browser-only rendering could not be determined because ' + 'its initializer threw.', ); expect(getVisibleChildren(container)).toEqual(Fallback); }); // @gate enableBrowserAPI it('errors if browser-only content is rendered outside Suspense', async () => { const browserReason = 'Only render this content in a browser'; const browserValue = ReactDOM.browser(browserReason); function BrowserOnly() { use(browserValue); return Browser; } const reportedErrors = []; const browserBailouts = []; let shellReady = false; let shellError; await act(() => { renderToPipeableStream(, { onError(error) { reportedErrors.push(error); }, onBrowserBailout(error) { browserBailouts.push(error); }, onShellReady() { shellReady = true; }, onShellError(error) { shellError = error; }, }); }); expect(shellError).toBeInstanceOf(Error); expect(shellError.message).toBe( 'The server render could not complete because client rendering was ' + "requested outside a Suspense boundary. See this error's cause for " + 'additional details.', ); expect(shellError.cause).toBe(browserReason); expect(shellError.stack).toContain('BrowserOnly'); expect(shellError.stack.split('\n')[0]).toBe( 'Error: ' + shellError.message, ); expect(shellReady).toBe(false); expect(reportedErrors).toEqual([shellError]); expect(browserBailouts).toEqual([]); }); // @gate enableBrowserAPI it('can abort all pending boundaries into browser-only rendering', async () => { const never = new Promise(() => {}); let isClient = false; function Pending({children}) { if (!isClient) { use(never); } return {children}; } function App() { return (
Shell Loading A}> A Loading B}> B
); } const serverErrors = []; const browserBailouts = []; const browserReason = {code: 'render-pending-content-in-browser'}; const initializeReason = jest.fn(() => browserReason); const browserValue = ReactDOM.browser(initializeReason); let abort; await act(() => { const controls = renderToPipeableStream(, { onError(error) { serverErrors.push(error); }, onBrowserBailout(error) { browserBailouts.push(error); }, }); abort = controls.abort; controls.pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
Shell Loading A Loading B
, ); await act(() => { function abortToBrowser() { abort(browserValue); } abortToBrowser(); }); expect(serverErrors).toEqual([]); expect(initializeReason).toHaveBeenCalledTimes(1); expect(browserBailouts).toHaveLength(2); expect(browserBailouts[0]).toBeInstanceOf(Error); expect(browserBailouts[0].message).toBe( 'Browser-only rendering was requested by `browser()`.', ); expect(browserBailouts[0].stack).toContain('abortToBrowser'); expect(browserBailouts[0].cause).toBe(browserReason); expect(browserBailouts[1]).toBe(browserBailouts[0]); isClient = true; const recoverableErrors = []; ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { recoverableErrors.push(error); }, }); await waitForAll([]); expect(recoverableErrors).toEqual([]); expect(getVisibleChildren(container)).toEqual(
Shell A B
, ); }); // @gate enableBrowserAPI it('errors if aborted with browser() before the shell completes', async () => { const never = new Promise(() => {}); let browserReason; const initializeReason = jest.fn(() => { browserReason = new Error('Only abort this render on the server'); return browserReason; }); const browserValue = ReactDOM.browser(initializeReason); function PendingRoot() { use(never); return Root; } const reportedErrors = []; const browserBailouts = []; let shellReady = false; let shellError; let abort; await act(() => { const controls = renderToPipeableStream(, { onError(error) { reportedErrors.push(error); }, onBrowserBailout(error) { browserBailouts.push(error); }, onShellReady() { shellReady = true; }, onShellError(error) { shellError = error; }, }); abort = controls.abort; }); await act(() => { function abortToBrowser() { abort(browserValue); } abortToBrowser(); }); expect(shellError).toBeInstanceOf(Error); expect(initializeReason).toHaveBeenCalledTimes(1); expect(shellError.message).toBe( 'The server render could not complete because client rendering was ' + "requested outside a Suspense boundary. See this error's cause for " + 'additional details.', ); expect(shellError.cause).toBe(browserReason); expect(shellError.stack).toContain('abortToBrowser'); expect(shellReady).toBe(false); expect(reportedErrors).toEqual([shellError]); expect(browserBailouts).toEqual([]); }); // @gate enableBrowserAPI it('reports nested browser bailouts if aborting fatals the shell', async () => { const never = new Promise(() => {}); const browserReason = 'Abort pending work into browser rendering'; const browserValue = ReactDOM.browser(browserReason); function Pending() { use(never); return Pending; } const reportedErrors = []; const browserBailouts = []; let shellError; let abort; await act(() => { const controls = renderToPipeableStream( <> Fallback}> Fallback}> , { onError(error) { reportedErrors.push(error); }, onBrowserBailout(error) { browserBailouts.push(error); }, onShellError(error) { shellError = error; }, }, ); abort = controls.abort; }); await act(() => { abort(browserValue); }); expect(shellError).toBeInstanceOf(Error); expect(shellError.message).toBe( 'The server render could not complete because client rendering was ' + "requested outside a Suspense boundary. See this error's cause for " + 'additional details.', ); expect(shellError.cause).toBe(browserReason); expect(reportedErrors).toHaveLength(2); expect(reportedErrors[0]).toBe(shellError); expect(reportedErrors[1].message).toBe(shellError.message); expect(reportedErrors[1].cause).toBe(browserReason); expect(browserBailouts).toHaveLength(2); expect(browserBailouts[0]).toBe(browserBailouts[1]); expect(browserBailouts[0]).not.toBe(shellError); expect(browserBailouts[0].message).toBe( 'Browser-only rendering was requested by `browser()`.', ); expect(browserBailouts[0].cause).toBe(browserReason); }); // @gate enableBrowserAPI it('uses a fallback if a browser reason initializer throws during abort', async () => { const never = new Promise(() => {}); const reasonError = new Error('Failed to initialize browser reason'); const initializeReason = jest.fn(() => { throw reasonError; }); const browserValue = ReactDOM.browser(initializeReason); function PendingRoot() { use(never); return Root; } const reportedErrors = []; const browserBailouts = []; let shellError; let abort; await act(() => { const controls = renderToPipeableStream(, { onError(error) { reportedErrors.push(error); }, onBrowserBailout(error) { browserBailouts.push(error); }, onShellError(error) { shellError = error; }, }); abort = controls.abort; }); await act(() => { abort(browserValue); }); expect(initializeReason).toHaveBeenCalledTimes(1); expect(shellError).toBeInstanceOf(Error); expect(shellError.cause).toBe( 'The reason for browser-only rendering could not be determined because ' + 'its initializer threw.', ); expect(reportedErrors).toEqual([shellError]); expect(browserBailouts).toEqual([]); }); // @gate enableBrowserAPI it('reports the browser value if it is thrown instead of passed to use', async () => { const initializeReason = jest.fn( () => new Error('Only render this content in a browser'), ); const browserValue = ReactDOM.browser(initializeReason); function BrowserOnly() { throw browserValue; } const reportedErrors = []; const browserBailouts = []; await act(() => { const {pipe} = renderToPipeableStream( Fallback}> , { onError(error) { reportedErrors.push(error); }, onBrowserBailout(error) { browserBailouts.push(error); }, }, ); pipe(writable); }); expect(reportedErrors).toEqual([browserValue]); expect(browserBailouts).toEqual([]); expect(initializeReason).not.toHaveBeenCalled(); expect(getVisibleChildren(container)).toEqual(Fallback); }); ['', 'BROWSER'].forEach(userDigest => { it(`does not reserve the ${JSON.stringify( userDigest, )} user error digest for browser rendering`, async () => { let isClient = false; const serverError = new Error('Server error'); function ServerError() { if (!isClient) { throw serverError; } return Client; } function App() { return ( Fallback}> ); } const serverErrors = []; await act(() => { const {pipe} = renderToPipeableStream(, { onError(error) { serverErrors.push(error); return userDigest; }, }); pipe(writable); }); expect(serverErrors).toEqual([serverError]); expect(getVisibleChildren(container)).toEqual(Fallback); isClient = true; const recoverableErrors = []; ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { recoverableErrors.push(error); }, }); await waitForAll([]); expect(recoverableErrors).toHaveLength(1); expect(recoverableErrors[0].digest).toBe(userDigest || undefined); expect(getVisibleChildren(container)).toEqual(Client); }); }); it('should asynchronously load a lazy component', async () => { let resolveA; const LazyA = React.lazy(() => { return new Promise(r => { resolveA = r; }); }); let resolveB; const LazyB = React.lazy(() => { return new Promise(r => { resolveB = r; }); }); class TextWithPunctuation extends React.Component { render() { return ; } } // This tests that default props of the inner element is resolved. TextWithPunctuation.defaultProps = { punctuation: '!', }; await act(() => { const {pipe} = renderToPipeableStream(
}>
}>
, ); pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
Loading...
Loading...
, ); await act(() => { resolveA({default: Text}); }); expect(getVisibleChildren(container)).toEqual(
Hello
Loading...
, ); await act(() => { resolveB({default: TextWithPunctuation}); }); expect(getVisibleChildren(container)).toEqual(
Hello
world!
, ); }); it('#23331: does not warn about hydration mismatches if something suspended in an earlier sibling', async () => { const makeApp = () => { let resolve; const imports = new Promise(r => { resolve = () => r({default: () => async}); }); const Lazy = React.lazy(() => imports); const App = () => (
Loading...}> after
); return [App, resolve]; }; // Server-side const [App, resolve] = makeApp(); await act(() => { const {pipe} = renderToPipeableStream(); pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
Loading...
, ); await act(() => { resolve(); }); expect(getVisibleChildren(container)).toEqual(
async after
, ); // Client-side const [HydrateApp, hydrateResolve] = makeApp(); await act(() => { ReactDOMClient.hydrateRoot(container, ); }); expect(getVisibleChildren(container)).toEqual(
async after
, ); await act(() => { hydrateResolve(); }); expect(getVisibleChildren(container)).toEqual(
async after
, ); }); it('should support nonce for bootstrap and runtime scripts', async () => { CSPnonce = 'R4nd0m'; try { let resolve; const Lazy = React.lazy(() => { return new Promise(r => { resolve = r; }); }); await act(() => { const {pipe} = renderToPipeableStream(
}>
, { nonce: 'R4nd0m', bootstrapScriptContent: 'function noop(){}', bootstrapScripts: [ 'init.js', {src: 'init2.js', integrity: 'init2hash'}, ], bootstrapModules: [ 'init.mjs', {src: 'init2.mjs', integrity: 'init2hash'}, ], }, ); pipe(writable); }); expect(getVisibleChildren(container)).toEqual([ , , , ,
Loading...
, ]); // 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([ , , , ,
Hello
, ]); } finally { CSPnonce = null; } }); it('should not automatically add nonce to rendered scripts', async () => { CSPnonce = 'R4nd0m'; try { await act(async () => { const {pipe} = renderToPipeableStream( `, ``, ``, ``, ``, ``, ``, ]); } finally { CSPnonce = null; } }); it('should client render a boundary if a lazy component rejects', async () => { let rejectComponent; const promise = new Promise((resolve, reject) => { rejectComponent = reject; }); const LazyComponent = React.lazy(() => { return promise; }); const LazyLazy = React.lazy(async () => { return { default: LazyComponent, }; }); function Wrapper({children}) { return children; } const LazyWrapper = React.lazy(() => { return { then(callback) { callback({ default: Wrapper, }); }, }; }); function App({isClient}) { return (
}> {isClient ? : }
); } 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 (
}>
); } const loggedErrors = []; function onError(x, errorInfo) { loggedErrors.push({ message: x.message, componentStack: errorInfo.componentStack, }); return 'Hash of (' + x.message + ')'; } loggedErrors.length = 0; await act(() => { const {pipe} = renderToPipeableStream(, { onError, }); pipe(writable); }); expect(loggedErrors.length).toBe(1); expect(loggedErrors[0].message).toBe('Bye'); expect(normalizeCodeLocInfo(loggedErrors[0].componentStack)).toBe( componentStack(['Throw', 'Suspense Fallback', 'div', 'Suspense', 'App']), ); }); it('should asynchronously load a lazy element', async () => { let resolveElement; const lazyElement = React.lazy(() => { return new Promise(r => { resolveElement = r; }); }); await act(() => { const {pipe} = renderToPipeableStream(
}> {lazyElement}
, ); pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
Loading...
); // 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 (
loading...}>
); } const loggedErrors = []; function onError(x) { loggedErrors.push(x); return 'hash(' + x.message + ')'; } const expectedDigest = onError(theError); loggedErrors.length = 0; await act(() => { const {pipe} = renderToPipeableStream( , { onError, }, ); pipe(writable); }); expect(loggedErrors).toEqual([theError]); const errors = []; // Attempt to hydrate the content. ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error, errorInfo) { errors.push({error, errorInfo}); }, }); await waitForAll([]); expect(getVisibleChildren(container)).toEqual(
Hello World
); 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 (
}> {isClient ? : }
); } const loggedErrors = []; const theError = new Error('uh oh'); function onError(x) { loggedErrors.push(x); return 'hash(' + x.message + ')'; } const expectedDigest = onError(theError); loggedErrors.length = 0; 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([]); expect(getVisibleChildren(container)).toEqual(
Loading...
); await act(() => { rejectComponent(theError); }); expect(loggedErrors).toEqual([theError]); 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', '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 asynchronously load the suspense boundary', async () => { await act(() => { const {pipe} = renderToPipeableStream(
}>
, ); pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
Loading...
); await act(() => { resolveText('Hello World'); }); expect(getVisibleChildren(container)).toEqual(
Hello World
); }); 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(
{'Loading A'} {'Loading B'} {'Loading C'}
, ); await act(async () => { await resolveText('A'); }); await waitForAll([]); expect(getVisibleChildren(container)).toEqual(
A B C
, ); 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.
}> <>
, { identifierPrefix: 'A_', onShellReady() { writableA.write('
'); pipe(writableA); writableA.write('
'); }, }, ); }); await act(() => { const {pipe} = renderToPipeableStream(
}>
, { identifierPrefix: 'B_', onShellReady() { writableB.write('
'); pipe(writableB); writableB.write('
'); }, }, ); }); expect(getVisibleChildren(container)).toEqual([
Loading A...
,
Loading B...
, ]); await act(() => { resolveText('B'); }); expect(getVisibleChildren(container)).toEqual([
Loading A...
,
This will show B:
B
, ]); await act(() => { resolveText('A'); }); // We're done writing both streams now. writable.end(); expect(getVisibleChildren(container)).toEqual([
This will show A:
A
,
This will show B:
B
, ]); }); it('can resolve async content in esoteric parents', async () => { function AsyncOption({text}) { return ; } function AsyncCol({className}) { return ; } function AsyncPath({id}) { return ; } function AsyncMi({id}) { return ; } function App() { return (
); } await act(() => { const {pipe} = renderToPipeableStream(); pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
Loading...
, ); await act(() => { resolveText('Hello'); }); await act(() => { resolveText('World'); }); await act(() => { resolveText('my-path'); resolveText('my-mi'); }); expect(getVisibleChildren(container)).toEqual(
, ); expect(container.querySelector('#my-path').namespaceURI).toBe( 'http://www.w3.org/2000/svg', ); expect(container.querySelector('#my-mi').namespaceURI).toBe( 'http://www.w3.org/1998/Math/MathML', ); }); it('can resolve async content in table parents', async () => { function AsyncTableBody({className, children}) { return {children}; } function AsyncTableRow({className, children}) { return {children}; } function AsyncTableCell({text}) { return {readText(text)}; } function App() { return ( }>
Loading...
); } await act(() => { const {pipe} = renderToPipeableStream(); pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
Loading...
, ); await act(() => { resolveText('A'); }); await act(() => { resolveText('B'); }); await act(() => { resolveText('C'); }); expect(getVisibleChildren(container)).toEqual(
C
, ); }); it('can stream into an SVG container', async () => { function AsyncPath({id}) { return ; } function App() { return ( Loading...
}> ); } await act(() => { const {pipe} = renderToPipeableStream( , { namespaceURI: 'http://www.w3.org/2000/svg', onShellReady() { writable.write(''); pipe(writable); writable.write(''); }, }, ); }); expect(getVisibleChildren(container)).toEqual( Loading... , ); await act(() => { resolveText('my-path'); }); expect(getVisibleChildren(container)).toEqual( , ); expect(container.querySelector('#my-path').namespaceURI).toBe( 'http://www.w3.org/2000/svg', ); }); function normalizeCodeLocInfo(str) { return ( str && String(str).replace(/\n +(?:at|in) ([^\(]+) [^\n]*/g, function (m, name) { return '\n in ' + name + ' (at **)'; }) ); } it('should include a component stack across suspended boundaries', async () => { function B() { const children = [readText('Hello'), readText('World')]; // Intentionally trigger a key warning here. return (
{children.map(function mapper(t) { return {t}; })}
); } function C() { return ( ); } function A() { return (
}>
); } await act(() => { const {pipe} = renderToPipeableStream(); pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
Loading
, ); 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(
Hello World
, ); }); // @gate !disableLegacyContext it('should can suspend in a class component with legacy context', async () => { class TestProvider extends React.Component { static childContextTypes = { test: PropTypes.string, }; state = {ctxToSet: null}; static getDerivedStateFromProps(props, state) { return {ctxToSet: props.ctx}; } getChildContext() { return { test: this.state.ctxToSet, }; } render() { return this.props.children; } } class TestConsumer extends React.Component { static contextTypes = { test: PropTypes.string, }; render() { const child = ( ); if (this.props.prefix) { return ( <> {readText(this.props.prefix)} {child} ); } return child; } } await act(() => { const {pipe} = renderToPipeableStream(
}>
, ); 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(
Loading: A
, ); await act(() => { resolveText('Hello: '); }); expect(getVisibleChildren(container)).toEqual(
Hello: B A
, ); }); it('should resume the context from where it left off', async () => { const ContextA = React.createContext('A0'); const ContextB = React.createContext('B0'); function PrintA() { return ( {value => } ); } class PrintB extends React.Component { static contextType = ContextB; render() { return ; } } function AsyncParent({text, children}) { return ( <> {children} ); } await act(() => { const {pipe} = renderToPipeableStream(
}>
, ); pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
A0
Loading...
A0
, ); await act(() => { resolveText('Child:'); }); expect(getVisibleChildren(container)).toEqual(
A0
Child:A0.1B0
A0
, ); }); it('should recover the outer context when an error happens inside a provider', async () => { const ContextA = React.createContext('A0'); const ContextB = React.createContext('B0'); function PrintA() { return ( {value => } ); } class PrintB extends React.Component { static contextType = ContextB; render() { return ; } } function Throws() { const value = React.useContext(ContextA); throw new Error(value); } const loggedErrors = []; await act(() => { const {pipe} = renderToPipeableStream(
, { onError(x) { loggedErrors.push(x); }, }, ); pipe(writable); }); expect(loggedErrors.length).toBe(1); expect(loggedErrors[0].message).toEqual('A0.1.1'); expect(getVisibleChildren(container)).toEqual(
A0
Loading...B0
A0
, ); }); 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(
}>
Inner
}>
}>
, ); pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
Loading Outer
); // 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(
Loading...
, ); await clientAct(() => { resolveText('Yay!'); }); assertLog(['Yay!']); 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('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 =>
  • {item.get('name')}
  • ); await act(() => { const {pipe} = renderToPipeableStream(
      {mappedJSX}
    ); pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
    • a
    • b
    , ); }); // @gate enableAsyncIterableChildren it('supports async generator component', async () => { async function* App() { yield {await Promise.resolve('Hi')}; yield ' '; yield {await Promise.resolve('World')}; } await act(async () => { const {pipe} = renderToPipeableStream(
    , ); 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(
    Hi World
    , ); }); // @gate enableAsyncIterableChildren it('supports async iterable children', async () => { const iterable = { async *[Symbol.asyncIterator]() { yield {await Promise.resolve('Hi')}; yield ' '; yield {await Promise.resolve('World')}; }, }; function App({children}) { return
    {children}
    ; } 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(
    Hi World
    , ); }); it('supports bigint', async () => { await act(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
    {10n}
    , ); pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
    10
    ); }); 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 (

    ); } let abort; const loggedErrors = []; await act(() => { const {pipe, abort: abortImpl} = renderToPipeableStream(, { onError(error) { loggedErrors.push(error.message); return 'a digest'; }, }); abort = abortImpl; pipe(writable); }); expect(loggedErrors).toEqual([]); expect(getVisibleChildren(container)).toEqual(

    p

    span
    , ); await act(() => { abort(new Error('uh oh')); }); expect(loggedErrors).toEqual(['uh oh', 'uh oh']); 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' + 'uh oh', 'a digest', componentStack(['AsyncText', 'Suspense', 'p', 'div', 'App']), ], [ 'Switched to client rendering because the server rendering aborted due to:\n\n' + 'uh oh', '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('reports abort errors for every suspended task when aborting fatals the shell', async () => { const promise = new Promise(() => {}); const rendered = []; function Suspend({label}) { rendered.push(label); use(promise); return null; } function App() { return ( <> ); } const errors = []; let abort; await act(() => { abort = renderToPipeableStream(, { onError(error) { errors.push(error.message); }, onShellError() {}, }).abort; }); expect(rendered).toEqual(['boundary', 'root one', 'root two']); await act(() => { abort(new Error('abort reason')); }); expect(errors).toEqual(['abort reason', 'abort reason', 'abort reason']); }); it('uses a rejection reason from a lazy component before the abort finishes', async () => { let reject; const Lazy = React.lazy( () => new Promise((resolve, rejectPromise) => { reject = rejectPromise; }), ); const haltedPromise = new Promise(() => {}); function HaltedWait() { use(haltedPromise); return null; } const errors = []; let abort; await act(() => { const controls = renderToPipeableStream( <> , { onError(error) { errors.push(error.message); }, }, ); abort = controls.abort; controls.pipe(writable); }); await act(() => { abort(new Error('abort reason')); reject(new Error('rejected during abort')); }); expect(errors).toEqual(['rejected during abort', 'abort reason']); }); it('does not report a rejection reason after abort has finished', async () => { let reject; const promise = new Promise((resolve, rejectPromise) => { reject = rejectPromise; }); function Wait() { use(promise); return null; } const errors = []; let abort; await act(() => { const controls = renderToPipeableStream( , { onError(error) { errors.push(error.message); }, }, ); abort = controls.abort; controls.pipe(writable); }); await act(() => { abort(new Error('abort reason')); }); await act(() => { reject(new Error('rejected after abort')); }); expect(errors).toEqual(['abort reason']); }); it('warns in dev if you access digest from errorInfo in onRecoverableError', async () => { await act(() => { const {pipe} = renderToPipeableStream(
    , { onError(error) { return 'a digest'; }, }, ); rejectText('hello'); pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
    loading...
    ); ReactDOMClient.hydrateRoot( container,
    hello
    , { 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( ' + (gate(flags => flags.shouldUseFizzExternalRuntime) ? '' : '') + (gate(flags => flags.enableFizzBlockingRender) ? '' : ''), ); }); // bugfix: https://github.com/facebook/react/issues/27286 it('can render custom elements with children on ther server', async () => { await act(() => { renderToPipeableStream(
    foo
    , ).pipe(writable); }); expect(getVisibleChildren(document)).toEqual(
    foo
    , ); }); // 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(
    hello
    , {onHeaders, onError})); }); expect(errors).toEqual(['bad onHeaders']); await act(() => { pipe(writable); }); expect(getVisibleChildren(container)).toEqual(
    hello
    ); }); it('accounts for the length of the interstitial between links when computing the headers length', async () => { let headers = null; function onHeaders(x) { headers = x; } function App() { // 20 bytes ReactDOM.preconnect('01'); // 42 bytes ReactDOM.preconnect('02'); // 64 bytes ReactDOM.preconnect('03'); // 86 bytes ReactDOM.preconnect('04'); // 108 bytes ReactDOM.preconnect('05'); // 130 bytes ReactDOM.preconnect('06'); // 152 bytes ReactDOM.preconnect('07'); // 174 bytes ReactDOM.preconnect('08'); // 196 bytes ReactDOM.preconnect('09'); // 218 bytes ReactDOM.preconnect('10'); // 240 bytes ReactDOM.preconnect('11'); // 262 bytes ReactDOM.preconnect('12'); // 284 bytes ReactDOM.preconnect('13'); // 306 bytes ReactDOM.preconnect('14'); return ( hello ); } await act(() => { renderToPipeableStream(, {onHeaders, maxHeadersLength: 305}); }); expect(headers.Link.length).toBe(284); await act(() => { renderToPipeableStream(, {onHeaders, maxHeadersLength: 306}); }); expect(headers.Link.length).toBe(306); }); it('does not perform any additional work after fatally erroring', async () => { let resolve: () => void; const promise = new Promise(r => { resolve = r; }); function AsyncComp() { React.use(promise); return Async; } let didRender = false; function DidRender({children}) { didRender = true; return children; } function ErrorComp() { throw new Error('boom'); } function App() { return (
    ); } let pipe; const errors = []; let didFatal = true; await act(() => { pipe = renderToPipeableStream(, { onError(error) { errors.push(error.message); }, onShellError(error) { didFatal = true; }, }).pipe; }); expect(didRender).toBe(false); await act(() => { resolve(); }); expect(didRender).toBe(false); const testWritable = new Stream.Writable(); await act(() => pipe(testWritable)); expect(didRender).toBe(false); expect(didFatal).toBe(didFatal); expect(errors).toEqual(['boom']); }); it('does not report aborts after fatally erroring', async () => { const promise = new Promise(() => {}); function AsyncComp() { React.use(promise); return 'Async'; } function ErrorComp() { throw new Error('boom'); } const errors = []; let abort; await act(() => { abort = renderToPipeableStream(
    , { onError(error) { errors.push(error.message); }, onShellError() {}, }, ).abort; }); expect(errors).toEqual(['boom']); await act(() => { abort(new Error('too late')); }); expect(errors).toEqual(['boom']); }); describe('error escaping', () => { it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => { window.__outlet = {}; const dangerousErrorString = '">