/** * Copyright (c) 2013-present, Facebook, Inc. * * 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'; let ExecutionEnvironment; let PropTypes; let React; let ReactDOM; let ReactDOMServer; let ReactTestUtils; const stream = require('stream'); const TEXT_NODE_TYPE = 3; // Helper functions for rendering tests // ==================================== // promisified version of ReactDOM.render() function asyncReactDOMRender(reactElement, domElement, forceHydrate) { return new Promise(resolve => { if (forceHydrate) { ReactDOM.hydrate(reactElement, domElement); } else { ReactDOM.render(reactElement, domElement); } // We can't use the callback for resolution because that will not catch // errors. They're thrown. resolve(); }); } // 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.calls && console.error.calls.reset) { console.error.calls.reset(); } else { spyOn(console, 'error'); } const result = await fn(); if ( console.error.calls.count() !== count && console.error.calls.count() !== 0 ) { console.log( `We expected ${count} warning(s), but saw ${console.error.calls.count()} warning(s).`, ); if (console.error.calls.count() > 0) { console.log(`We saw these warnings:`); for (var i = 0; i < console.error.calls.count(); i++) { console.log(console.error.calls.argsFor(i)[0]); } } } expectDev(console.error.calls.count()).toBe(count); 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 expectErrors(async () => { ExecutionEnvironment.canUseDOM = true; await asyncReactDOMRender(reactElement, domElement, forceHydrate); ExecutionEnvironment.canUseDOM = false; return domElement.firstChild; }, errorCount); } async function renderIntoString(reactElement, errorCount = 0) { return await expectErrors( () => new Promise(resolve => resolve(ReactDOMServer.renderToString(reactElement)), ), 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 renderIntoString(reactElement, errorCount); var domElement = document.createElement('div'); domElement.innerHTML = markup; return domElement.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 expectErrors( () => new Promise(resolve => { let writable = new DrainWritable(); ReactDOMServer.renderToNodeStream(reactElement).pipe(writable); writable.on('finish', () => resolve(writable.buffer)); }), 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 renderIntoStream(reactElement, errorCount); var domElement = document.createElement('div'); domElement.innerHTML = markup; return domElement.firstChild; } const clientCleanRender = (element, errorCount = 0) => { const div = document.createElement('div'); return renderIntoDom(element, div, false, errorCount); }; const clientRenderOnServerString = async (element, errorCount = 0) => { const markup = await renderIntoString(element, errorCount); resetModules(); var domElement = document.createElement('div'); domElement.innerHTML = markup; let serverNode = domElement.firstChild; const firstClientNode = await renderIntoDom( element, domElement, true, errorCount, ); let clientNode = firstClientNode; // Make sure all top level nodes match up while (serverNode || clientNode) { expect(serverNode != null).toBe(true); expect(clientNode != null).toBe(true); expect(clientNode.nodeType).toBe(serverNode.nodeType); // Assert that the DOM element hasn't been replaced. // Note that we cannot use expect(serverNode).toBe(clientNode) because // of jest bug #1772. expect(serverNode === clientNode).toBe(true); 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. var domElement = document.createElement('div'); domElement.innerHTML = '
'; await renderIntoDom(element, domElement, true, errorCount + 1); // This gives us the resulting text content. var hydratedTextContent = domElement.textContent; // Next we render the element into a clean DOM node client side. const cleanDomElement = document.createElement('div'); ExecutionEnvironment.canUseDOM = true; await asyncReactDOMRender(element, cleanDomElement, true); ExecutionEnvironment.canUseDOM = false; // This gives us the expected text content. const cleanTextContent = cleanDomElement.textContent; // The only guarantee is that text content has been patched up if needed. expect(hydratedTextContent).toBe(cleanTextContent); // 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) { it(`renders ${desc} with server string render`, () => testFn(serverRender)); it(`renders ${desc} with server stream render`, () => testFn(streamRender)); itClientRenders(desc, 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) { it(`renders ${desc} with clean client render`, () => testFn(clientCleanRender)); it(`renders ${desc} with client render on top of good server markup`, () => testFn(clientRenderOnServerString)); it(`renders ${desc} with client render on top of bad server markup`, async () => { try { await testFn(clientRenderOnBadMarkup); } catch (x) { // We expect this to trigger the BadMarkupExpected rejection. if (!(x instanceof BadMarkupExpected)) { // If not, rethrow. throw x; } } }); } function itThrows(desc, testFn, partialMessage) { it(`throws ${desc}`, () => { return testFn().then( () => expect(false).toBe('The promise resolved and should not have.'), err => { expect(err).toBeInstanceOf(Error); expect(err.message).toContain(partialMessage); }, ); }); } function itThrowsWhenRendering(desc, testFn, partialMessage) { itThrows( `when rendering ${desc} with server string render`, () => testFn(serverRender), partialMessage, ); itThrows( `when rendering ${desc} with clean client render`, () => testFn(clientCleanRender), 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`, () => testFn((element, warningCount = 0) => clientRenderOnBadMarkup(element, warningCount - 1), ), 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 serverRender(serverElement); 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); } // When there is a test that renders on server and then on client and expects a logged // error, you want to see the error show up both on server and client. Unfortunately, // React refuses to issue the same error twice to avoid clogging up the console. // To get around this, we must reload React modules in between server and client render. function resetModules() { // First, reset the modules to load the client renderer. jest.resetModuleRegistry(); // TODO: can we express this test with only public API? ExecutionEnvironment = require('fbjs/lib/ExecutionEnvironment'); PropTypes = require('prop-types'); React = require('react'); ReactDOM = require('react-dom'); ReactTestUtils = require('react-dom/test-utils'); // Now we reset the modules again to load the server renderer. // Resetting is important because we want to avoid any shared state // influencing the tests. jest.resetModuleRegistry(); ReactDOMServer = require('react-dom/server'); } describe('ReactDOMServerIntegration', () => { beforeEach(() => { resetModules(); ExecutionEnvironment.canUseDOM = false; }); describe('basic rendering', function() { itRenders('a blank div', async render => { const e = await render(
); expect(e.tagName).toBe('DIV'); }); itRenders('a self-closing tag', async render => { const e = await render(
); expect(e.tagName).toBe('BR'); }); itRenders('a self-closing tag as a child', async render => { const e = await render(

); expect(e.childNodes.length).toBe(1); expect(e.firstChild.tagName).toBe('BR'); }); itRenders('a string', async render => { let e = await render('Hello'); expect(e.nodeType).toBe(3); expect(e.nodeValue).toMatch('Hello'); }); itRenders('a number', async render => { let e = await render(42); expect(e.nodeType).toBe(3); expect(e.nodeValue).toMatch('42'); }); itRenders('an array with one child', async render => { let e = await render([
text1
]); let parent = e.parentNode; expect(parent.childNodes[0].tagName).toBe('DIV'); }); itRenders('an array with several children', async render => { let Header = props => { return

header

; }; let Footer = props => { return [

footer

,

about

]; }; let e = await render([
text1
, text2,
,