From b1a109b4af2a1e195c0eb158a7654a7a2030e29f Mon Sep 17 00:00:00 2001 From: Sasha Aickin Date: Fri, 22 Apr 2016 09:58:32 -0700 Subject: [PATCH 1/9] Changed strategy for rendering into server-generated markup from using a string checksum to walking the server-generated DOM and comparing to the client component tree. Reworked the way that child crawling works to get all unit tests to pass. Added a bunch more unit tests and fixed code so that those worked too. Still a lot more tests to write, esp around interactivity. Events were broken when reconnecting markup. Fixed that. Inline styles caused a markup mismatch. Fixed that. Small refactor of server unit tests to make them more independent. Reorganized markup reconnection tests for better readability. Added a big unit for reconnecting ES6 class components, createClass components, pure components, and literal components. Added support for boolean attribute values. Also added unit tests that verify that user interaction with inputs before client-side reconnect does not cause a markup mismatch. Changed the test descriptions to fit jasmine's syntax. Added a few simple unit tests for the other namespaces (svg and mathml) Added a bunch of unit tests for wrapped components, including various kinds of uncontrolled form inputs. Fixed the bugs those tests revealed. Added two unit tests for self-closing tags. Added unit tests for newline-eating tags. Added unit tests for uncontrolled multiple selects. Added some unit tests for rendering controlled input fields. Added more tests for controlled and uncontrolled fields. Renaming and other polish Added unit tests for refs Polished up the markup mismatch error messages and API doc. Added a few component hierarchy unit tests. Updated documentation on the nodeToReuse argument to mountComponent. Changed its name to nodesToReuse. Added a path to the node in question for markup mismatch errors. --- .../dom/client/MarkupMismatchError.js | 216 ++++++++ src/renderers/dom/client/ReactMount.js | 194 +++---- .../dom/client/__tests__/ReactMount-test.js | 4 +- .../__tests__/ReactRenderDocument-test.js | 8 +- .../dom/client/wrappers/ReactDOMSelect.js | 1 + .../__tests__/ReactServerRendering-test.js | 509 ++++++++++++++++++ src/renderers/dom/shared/ReactDOMComponent.js | 209 ++++++- .../dom/shared/ReactDOMEmptyComponent.js | 13 +- .../dom/shared/ReactDOMTextComponent.js | 24 +- .../reconciler/ReactCompositeComponent.js | 28 +- .../shared/reconciler/ReactMultiChild.js | 17 +- .../shared/reconciler/ReactReconciler.js | 9 +- 12 files changed, 1114 insertions(+), 118 deletions(-) create mode 100644 src/renderers/dom/client/MarkupMismatchError.js diff --git a/src/renderers/dom/client/MarkupMismatchError.js b/src/renderers/dom/client/MarkupMismatchError.js new file mode 100644 index 0000000000000..488fb75859d08 --- /dev/null +++ b/src/renderers/dom/client/MarkupMismatchError.js @@ -0,0 +1,216 @@ +/** + * Copyright 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule MarkupMismatchError + */ + +'use strict'; + +var ELEMENT_NODE_TYPE = 1; +var TEXT_NODE_TYPE = 3; +var COMMENT_NODE_TYPE = 8; + +// we cannot use class MarkupMismatchError extends Error {} because Babel cannot extend built-in +// objects in a way that allows for instanceof checks. +// this error subclassing code is copied and modified from +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Custom_Error_Types +// According to https://developer.mozilla.org/en-US/docs/MDN/About#Copyrights_and_licenses, all code in MDN +// added on or after August 20, 2010 is public domain, and according to the wiki page history, the earliest +// version of this code was added on June 7, 2011: +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error$revision/62425 +function MarkupMismatchError(message, node, serverVersion, clientVersion) { + this.name = 'MarkupMismatchError'; + this.message = message || 'The pre-rendered markup did not match the component being rendered.'; + this.node = node; + this.serverVersion = serverVersion; + this.clientVersion = clientVersion; + this.stack = (new Error()).stack; +} +MarkupMismatchError.prototype = Object.create(Error.prototype); +MarkupMismatchError.prototype.constructor = MarkupMismatchError; + +/** + * throw an error when there is a child of the node in the client component tree + * that was not present in the server markup. + * @param {DOMElement} parent in the server markup + * @param {ReactElement} The React element of the child in the client component tree. + */ +function throwChildAddedError(node, child) { + let childDesc = 'An unknown type of child.'; + if (typeof child === 'string') { + childDesc = `The text ${child}`; + } else if (child.type) { + childDesc = `A child of type <${child.type}>`; + } + + throw new MarkupMismatchError( + `Added a child node.`, + node, + ``, + childDesc); +} + +/** + * throw an error when there is a child of the node in the server markup that + * is not present in the client component tree. + * @param {DOMNode} the DOM child that wasn't present in the client + * component tree. + */ +function throwChildMissingError(serverChild) { + let serverText = `A child node of type ${serverChild.nodeType}`; + switch (serverChild.nodeType) { + case ELEMENT_NODE_TYPE: + serverText = `A child <${serverChild.tagName}> element with text: '${abridgeContent(serverChild.textContent)}'`; + break; + case TEXT_NODE_TYPE: + serverText = `A text node with text '${abridgeContent(serverChild.textContent)}'`; + break; + } + throw new MarkupMismatchError( + 'Missing a child node.', + serverChild, + serverText, + '' + ); +} + +/** + * throw an error when there is a DOM node in the server markup that has a different + * tag than the client component tree. + * @param {DOMElement} the DOM element from the server markup + * @param {String} the client component tree element tag ("div", "span", etc.) + * component tree. + */ +function throwNodeTypeMismatchError(node, clientTagname) { + throw new MarkupMismatchError( + 'The DOM element types differed', + node, + `A <${node.tagName.toLowerCase()}> tag`, + `A <${clientTagname}> tag`); +} + +/** + * throw an error when there is a DOM attribute in the server markup that is not + * present in the client component tree. + * @param {DOMElement} the DOM element from the server markup that has an extra attribute + * @param {String} the attribute name + * @param {String} the attribute value in the server markup + */ +function throwAttributeMissingMismatchError(node, attr, value) { + throw new MarkupMismatchError(`The attribute '${attr}' is missing.`, node, `${attr}=${value}`, ``); +} + +/** + * throw an error when there is a DOM attribute in the client component tree that is not + * present in the server markup. + * @param {DOMElement} the DOM element from the server markup that is missing an attribute + * @param {String} the attribute name + * @param {String} the attribute value in the client component tree. + */ +function throwAttributeAddedMismatchError(node, attr, value) { + throw new MarkupMismatchError(`The attribute '${attr}' was added.`, node, ``, `${attr}=${value}`); +} + +/** + * throw an error when there is a DOM attribute in the client component tree that has a + * different value in the server markup. + * @param {DOMElement} the DOM element from the server markup that has the changed attribute + * @param {String} the attribute name + * @param {String} the attribute value in the server markup + * @param {String} the attribute value in the client component tree. + */ +function throwAttributeChangedMismatchError(node, attr, serverValue, clientValue) { + throw new MarkupMismatchError( + `The value of the attribute '${attr}' differed.`, + node, + `${attr}=${serverValue}`, + `${attr}=${clientValue}`); +} + +/** + * throw an error when html set with dangerouslySetInnerHTML has a + * different value in the server markup and client component tree. + * @param {DOMElement} the DOM element from the server markup that has the dangerouslySetInnerHTML + * @param {String} the dangerouslySetInnerHTML value in the server markup + * @param {String} the dangerouslySetInnerHTML value in the client component tree. + */ +function throwInnerHtmlMismatchError(node, serverValue, clientValue) { + throw new MarkupMismatchError( + 'The value for dangerouslySetInnerHTML differed.', + node, + serverValue, + clientValue + ); +} + +/** + * throw an error when the text content of a node has a + * different value in the server markup and client component tree. + * @param {DOMElement} the DOM element from the server markup that has differing text + * @param {String} the text value in the server markup + * @param {String} the text value in the client component tree. + */ +function throwTextMismatchError(node, serverValue, clientValue) { + throw new MarkupMismatchError('Text content differed.', node, `'${serverValue}'`, `'${clientValue}'`); +} + +/** + * throw an error when the component type (i.e. element, text, empty) of a node has a + * different value in the server markup and client component tree. + * @param {DOMNode|Array} the DOM element or elements from the server + * markup that is/are different on client + * @param {String} a user-readable description of the component in the client + * component tree. + */ +function throwComponentTypeMismatchError(node, clientComponentDesc) { + var serverValue = ''; + + if (Array.isArray(node) && node.length === 3) { + serverValue = `A text node with text: '${abridgeContent(node[1].textContent)}'`; + } else if (Array.isArray(node) && node.length === 2) { + serverValue = 'A blank text node.'; + } else if (node.nodeType === COMMENT_NODE_TYPE) { + serverValue = 'An empty (or null) node'; + } else if (node.nodeType === ELEMENT_NODE_TYPE) { + serverValue = `A <${node.tagName.toLowerCase()}> element with text: '${abridgeContent(node.textContent)}'`; + } else { + serverValue = `An illegal DOM node with text: '${abridgeContent(node.textContent)}'`; + } + + throw new MarkupMismatchError( + 'The type of component differed.', + Array.isArray(node) ? node[0] : node, + serverValue, + clientComponentDesc); +} + +/** + * truncate text utility function. + * @private + * @param {String} text to truncate + * @param {Number?} maximum length + */ +function abridgeContent(text, maxLength = 30) { + if (text.length < maxLength) { + return text; + } + return text.substr(0, maxLength) + '...'; +} + +module.exports = { + error: MarkupMismatchError, + throwChildAddedError, + throwChildMissingError, + throwNodeTypeMismatchError, + throwAttributeMissingMismatchError, + throwAttributeAddedMismatchError, + throwAttributeChangedMismatchError, + throwInnerHtmlMismatchError, + throwTextMismatchError, + throwComponentTypeMismatchError, +}; diff --git a/src/renderers/dom/client/ReactMount.js b/src/renderers/dom/client/ReactMount.js index 7f6f8cac6ff5f..8fbd74aa00e7f 100644 --- a/src/renderers/dom/client/ReactMount.js +++ b/src/renderers/dom/client/ReactMount.js @@ -13,6 +13,7 @@ var DOMLazyTree = require('DOMLazyTree'); var DOMProperty = require('DOMProperty'); +var MarkupMismatchError = require('MarkupMismatchError'); var ReactBrowserEventEmitter = require('ReactBrowserEventEmitter'); var ReactCurrentOwner = require('ReactCurrentOwner'); var ReactDOMComponentTree = require('ReactDOMComponentTree'); @@ -21,7 +22,6 @@ var ReactDOMFeatureFlags = require('ReactDOMFeatureFlags'); var ReactElement = require('ReactElement'); var ReactFeatureFlags = require('ReactFeatureFlags'); var ReactInstrumentation = require('ReactInstrumentation'); -var ReactMarkupChecksum = require('ReactMarkupChecksum'); var ReactPerf = require('ReactPerf'); var ReactReconciler = require('ReactReconciler'); var ReactUpdateQueue = require('ReactUpdateQueue'); @@ -38,27 +38,13 @@ var ATTR_NAME = DOMProperty.ID_ATTRIBUTE_NAME; var ROOT_ATTR_NAME = DOMProperty.ROOT_ATTRIBUTE_NAME; var ELEMENT_NODE_TYPE = 1; +var TEXT_NODE_TYPE = 3; +var COMMENT_NODE_TYPE = 8; var DOC_NODE_TYPE = 9; var DOCUMENT_FRAGMENT_NODE_TYPE = 11; var instancesByReactRootID = {}; -/** - * Finds the index of the first character - * that's not common between the two given strings. - * - * @return {number} the index of the character where the strings diverge - */ -function firstDifferenceIndex(string1, string2) { - var minLen = Math.min(string1.length, string2.length); - for (var i = 0; i < minLen; i++) { - if (string1.charAt(i) !== string2.charAt(i)) { - return i; - } - } - return string1.length === string2.length ? -1 : minLen; -} - /** * @param {DOMElement|DOMDocument} container DOM element that may contain * a React component @@ -83,6 +69,32 @@ function internalGetID(node) { return node.getAttribute && node.getAttribute(ATTR_NAME) || ''; } +function getUserReadableNodePath(node) { + var pathToNode = []; + while (node) { + if (node.nodeType === COMMENT_NODE_TYPE) { + pathToNode.unshift('#comment'); + } else if (node.nodeType === TEXT_NODE_TYPE) { + pathToNode.unshift('#text'); + } else if (node.nodeType === ELEMENT_NODE_TYPE) { + var elementName = node.tagName.toLowerCase(); + if (node.id) { + elementName = elementName + '#' + node.id; + } else if (node.className) { + elementName = elementName + '.' + node.className.split(' ').join('.'); + } + pathToNode.unshift(elementName); + if (node.hasAttribute(DOMProperty.ROOT_ATTRIBUTE_NAME)) { + break; + } + } else { + pathToNode.unshift('node'); + } + node = node.parentNode; + } + return pathToNode.join(' > '); +} + /** * Mounts this component and inserts it into the DOM. * @@ -109,13 +121,75 @@ function mountComponentIntoNode( console.time(markerName); } - var markup = ReactReconciler.mountComponent( - wrapperInstance, - transaction, - null, - ReactDOMContainerInfo(wrapperInstance, container), - context - ); + try { + var markup = ReactReconciler.mountComponent( + wrapperInstance, + transaction, + null, + ReactDOMContainerInfo(wrapperInstance, container), + context, + shouldReuseMarkup ? container.firstChild : undefined + ); + } catch (e) { + if (e instanceof MarkupMismatchError.error) { + // the server-generated markup had a mismatch with the client-side component; + // we need to inform the user and attempt to re-render if we can. + + invariant( + container.nodeType !== DOC_NODE_TYPE, + 'You\'re trying to render a component to the document using ' + + 'server rendering but the markup did not match. This usually ' + + 'means you rendered a different component type or props on ' + + 'the client from the one on the server, or your render() ' + + 'methods are impure. React cannot handle this case due to ' + + 'cross-browser quirks by rendering at the document root. You ' + + 'should look for environment dependent code in your components ' + + 'and ensure the props are the same client and server side:\n' + + 'Node: %s\n' + + 'Mismatch: %s\n' + + 'On server: %s\n' + + 'On client: %s\n', + getUserReadableNodePath(e.node), + e.message, + e.serverVersion, + e.clientVersion + ); + + if (__DEV__) { + warning( + false, + 'React attempted to reuse markup in a container but the ' + + 'markup did not match. This generally means that you are ' + + 'using server rendering and the markup generated on the ' + + 'server was not what the client was expecting. React injected ' + + 'new markup to compensate which works but you have lost many ' + + 'of the benefits of server rendering. Instead, figure out ' + + 'why the markup being generated is different on the client ' + + 'or server:\n' + + 'Node: %s\n' + + 'Mismatch: %s\n' + + 'On server: %s\n' + + 'On client: %s\n', + getUserReadableNodePath(e.node), + e.message, + e.serverVersion, + e.clientVersion + ); + } + + // render again with just blowing away the existing server-rendered markup. + markup = ReactReconciler.mountComponent( + wrapperInstance, + transaction, + null, + ReactDOMContainerInfo(wrapperInstance, container), + context + ); + shouldReuseMarkup = false; + } else { + throw e; + } + } if (markerName) { console.timeEnd(markerName); @@ -597,78 +671,12 @@ var ReactMount = { if (shouldReuseMarkup) { var rootElement = getReactRootElementInContainer(container); - if (ReactMarkupChecksum.canReuseMarkup(markup, rootElement)) { - ReactDOMComponentTree.precacheNode(instance, rootElement); - return; - } else { - var checksum = rootElement.getAttribute( - ReactMarkupChecksum.CHECKSUM_ATTR_NAME - ); - rootElement.removeAttribute(ReactMarkupChecksum.CHECKSUM_ATTR_NAME); - - var rootMarkup = rootElement.outerHTML; - rootElement.setAttribute( - ReactMarkupChecksum.CHECKSUM_ATTR_NAME, - checksum - ); - - var normalizedMarkup = markup; - if (__DEV__) { - // because rootMarkup is retrieved from the DOM, various normalizations - // will have occurred which will not be present in `markup`. Here, - // insert markup into a
or