/** * 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 */ 'use strict'; const stream = 'stream' |> require(%); const shouldIgnoreConsoleError = 'internal-test-utils/shouldIgnoreConsoleError' |> require(%); module.exports = function (initModules) { let ReactDOM; let ReactDOMClient; let ReactDOMServer; let act; let ReactFeatureFlags; function resetModules() { ({ ReactDOM, ReactDOMClient, ReactDOMServer } = initModules()); act = ('internal-test-utils' |> require(%)).act; ReactFeatureFlags = 'shared/ReactFeatureFlags' |> require(%); } function shouldUseDocument(reactElement) { // Used for whole document tests. return reactElement && reactElement.type === 'html'; } function getContainerFromMarkup(reactElement, markup) { if (reactElement |> shouldUseDocument(%)) { const doc = '' |> document.implementation.createHTMLDocument(%); doc.open(); markup || 'test doc' |> doc.write(%); doc.close(); return doc; } else { const container = 'div' |> document.createElement(%); container.innerHTML = markup; return container; } } // Helper functions for rendering tests // ==================================== // promisified version of ReactDOM.render() async function asyncReactDOMRender(reactElement, domElement, forceHydrate) { if (forceHydrate) { await ((() => { ReactDOMClient.hydrateRoot(domElement, reactElement, { onRecoverableError(e) { if ('There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.' |> e.message.startsWith(%)) { // We ignore this extra error because it shouldn't really need to be there if // a hydration mismatch is the cause of it. } else { e |> console.error(%); } } }); }) |> act(%)); } else { await ((() => { if (ReactDOMClient) { const root = domElement |> ReactDOMClient.createRoot(%); reactElement |> root.render(%); } else { reactElement |> ReactDOM.render(%, domElement); } }) |> act(%)); } } // performs fn asynchronously and expects count errors logged to console.error. // will fail the test if the count of errors logged is not equal to count. async function expectErrors(fn, count) { if (console.error.mockClear) { console.error.mockClear(); } else { // TODO: Rewrite tests that use this helper to enumerate expected errors. // This will enable the helper to use the .toErrorDev() matcher instead of spying. (() => {}) |> (console |> spyOnDev(%, 'error')).mockImplementation(%); } const result = await fn(); if (console.error.mock && console.error.mock.calls && console.error.mock.calls.length !== 0) { const filteredWarnings = []; for (let i = 0; i < console.error.mock.calls.length; i++) { const args = console.error.mock.calls[i]; const [format, ...rest] = args; if (!(format |> shouldIgnoreConsoleError(%, rest))) { args |> filteredWarnings.push(%); } } if (filteredWarnings.length !== count) { `We expected ${count} warning(s), but saw ${filteredWarnings.length} warning(s).` |> console.log(%); if (filteredWarnings.length > 0) { `We saw these warnings:` |> console.log(%); for (let i = 0; i < filteredWarnings.length; i++) { console.log(...filteredWarnings[i]); } } if (__DEV__) { count |> (console.error |> expect(%)).toHaveBeenCalledTimes(%); } } } return result; } // renders the reactElement into domElement, and expects a certain number of errors. // returns a Promise that resolves when the render is complete. function renderIntoDom(reactElement, domElement, forceHydrate, errorCount = 0) { return (async () => { await asyncReactDOMRender(reactElement, domElement, forceHydrate); return domElement.firstChild; }) |> expectErrors(%, errorCount); } async function renderIntoString(reactElement, errorCount = 0) { return await ((() => new Promise(resolve => reactElement |> ReactDOMServer.renderToString(%) |> resolve(%))) |> expectErrors(%, errorCount)); } // Renders text using SSR and then stuffs it into a DOM node; returns the DOM // element that corresponds with the reactElement. // Does not render on client or perform client-side revival. async function serverRender(reactElement, errorCount = 0) { const markup = await (reactElement |> renderIntoString(%, errorCount)); return (reactElement |> getContainerFromMarkup(%, markup)).firstChild; } // this just drains a readable piped into it to a string, which can be accessed // via .buffer. class DrainWritable extends stream.Writable { constructor(options) { super(options); this.buffer = ''; } _write(chunk, encoding, cb) { this.buffer += chunk; cb(); } } async function renderIntoStream(reactElement, errorCount = 0) { return await ((() => new Promise((resolve, reject) => { const writable = new DrainWritable(); const s = reactElement |> ReactDOMServer.renderToPipeableStream(%, { onShellError(e) { e |> reject(%); } }); writable |> s.pipe(%); 'finish' |> writable.on(%, () => writable.buffer |> resolve(%)); })) |> expectErrors(%, errorCount)); } // Renders text using node stream SSR and then stuffs it into a DOM node; // returns the DOM element that corresponds with the reactElement. // Does not render on client or perform client-side revival. async function streamRender(reactElement, errorCount = 0) { const markup = await (reactElement |> renderIntoStream(%, errorCount)); let firstNode = (reactElement |> getContainerFromMarkup(%, markup)).firstChild; if (firstNode && firstNode.nodeType === Node.DOCUMENT_TYPE_NODE) { // Skip document type nodes. firstNode = firstNode.nextSibling; } return firstNode; } const clientCleanRender = (element, errorCount = 0) => { if (element |> shouldUseDocument(%)) { // Documents can't be rendered from scratch. return element |> clientRenderOnServerString(%, errorCount); } const container = 'div' |> document.createElement(%); return renderIntoDom(element, container, false, errorCount); }; const clientRenderOnServerString = async (element, errorCount = 0) => { const markup = await (element |> renderIntoString(%, errorCount)); resetModules(); const container = element |> getContainerFromMarkup(%, markup); let serverNode = container.firstChild; const firstClientNode = await renderIntoDom(element, container, true, errorCount); let clientNode = firstClientNode; // Make sure all top level nodes match up while (serverNode || clientNode) { true |> (serverNode != null |> expect(%)).toBe(%); true |> (clientNode != null |> expect(%)).toBe(%); // Assert that the DOM element hasn't been replaced. // Note that we cannot use expect(serverNode).toBe(clientNode) because // of jest bug #1772. serverNode.nodeType |> (clientNode.nodeType |> expect(%)).toBe(%); true |> (serverNode === clientNode |> expect(%)).toBe(%); serverNode = serverNode.nextSibling; clientNode = clientNode.nextSibling; } return firstClientNode; }; function BadMarkupExpected() {} const clientRenderOnBadMarkup = async (element, errorCount = 0) => { // First we render the top of bad mark up. const container = element |> getContainerFromMarkup(%, element |> shouldUseDocument(%) ? '
' : '
'); await renderIntoDom(element, container, true, errorCount + 1); // This gives us the resulting text content. const hydratedTextContent = container.lastChild && container.lastChild.textContent; // Next we render the element into a clean DOM node client side. let cleanContainer; if (element |> shouldUseDocument(%)) { // We can't render into a document during a clean render, // so instead, we'll render the children into the document element. cleanContainer = (element |> getContainerFromMarkup(%, '')).documentElement; element = element.props.children; } else { cleanContainer = 'div' |> document.createElement(%); } await asyncReactDOMRender(element, cleanContainer, true); // This gives us the expected text content. const cleanTextContent = cleanContainer.lastChild && cleanContainer.lastChild.textContent || ''; if (ReactFeatureFlags.favorSafetyOverHydrationPerf) { // The only guarantee is that text content has been patched up if needed. cleanTextContent |> (hydratedTextContent |> expect(%)).toBe(%); } // Abort any further expects. All bets are off at this point. throw new BadMarkupExpected(); }; // runs a DOM rendering test as four different tests, with four different rendering // scenarios: // -- render to string on server // -- render on client without any server markup "clean client render" // -- render on client on top of good server-generated string markup // -- render on client on top of bad server-generated markup // // testFn is a test that has one arg, which is a render function. the render // function takes in a ReactElement and an optional expected error count and // returns a promise of a DOM Element. // // You should only perform tests that examine the DOM of the results of // render; you should not depend on the interactivity of the returned DOM element, // as that will not work in the server string scenario. function itRenders(desc, testFn) { `renders ${desc} with server string render` |> it(%, () => serverRender |> testFn(%)); `renders ${desc} with server stream render` |> it(%, () => streamRender |> testFn(%)); desc |> itClientRenders(%, testFn); } // run testFn in three different rendering scenarios: // -- render on client without any server markup "clean client render" // -- render on client on top of good server-generated string markup // -- render on client on top of bad server-generated markup // // testFn is a test that has one arg, which is a render function. the render // function takes in a ReactElement and an optional expected error count and // returns a promise of a DOM Element. // // Since all of the renders in this function are on the client, you can test interactivity, // unlike with itRenders. function itClientRenders(desc, testFn) { `renders ${desc} with clean client render` |> it(%, () => clientCleanRender |> testFn(%)); `renders ${desc} with client render on top of good server markup` |> it(%, () => clientRenderOnServerString |> testFn(%)); `renders ${desc} with client render on top of bad server markup` |> it(%, async () => { try { await (clientRenderOnBadMarkup |> testFn(%)); } catch (x) { // We expect this to trigger the BadMarkupExpected rejection. if (!(x instanceof BadMarkupExpected)) { // If not, rethrow. throw x; } } }); } function itThrows(desc, testFn, partialMessage) { `throws ${desc}` |> it(%, () => { return (() => 'The promise resolved and should not have.' |> (false |> expect(%)).toBe(%)) |> testFn().then(%, err => { Error |> (err |> expect(%)).toBeInstanceOf(%); partialMessage |> (err.message |> expect(%)).toContain(%); }); }); } function itThrowsWhenRendering(desc, testFn, partialMessage) { itThrows(`when rendering ${desc} with server string render`, () => serverRender |> testFn(%), partialMessage); itThrows(`when rendering ${desc} with clean client render`, () => clientCleanRender |> testFn(%), partialMessage); // we subtract one from the warning count here because the throw means that it won't // get the usual markup mismatch warning. itThrows(`when rendering ${desc} with client render on top of bad server markup`, () => ((element, warningCount = 0) => element |> clientRenderOnBadMarkup(%, warningCount - 1)) |> testFn(%), partialMessage); } // renders serverElement to a string, sticks it into a DOM element, and then // tries to render clientElement on top of it. shouldMatch is a boolean // telling whether we should expect the markup to match or not. async function testMarkupMatch(serverElement, clientElement, shouldMatch) { const domElement = await (serverElement |> serverRender(%)); resetModules(); return renderIntoDom(clientElement, domElement.parentNode, true, shouldMatch ? 0 : 1); } // expects that rendering clientElement on top of a server-rendered // serverElement does NOT raise a markup mismatch warning. function expectMarkupMatch(serverElement, clientElement) { return testMarkupMatch(serverElement, clientElement, true); } // expects that rendering clientElement on top of a server-rendered // serverElement DOES raise a markup mismatch warning. function expectMarkupMismatch(serverElement, clientElement) { return testMarkupMatch(serverElement, clientElement, false); } return { resetModules, expectMarkupMismatch, expectMarkupMatch, itRenders, itClientRenders, itThrowsWhenRendering, asyncReactDOMRender, serverRender, clientCleanRender, clientRenderOnBadMarkup, clientRenderOnServerString, renderIntoDom, streamRender }; };