-
Notifications
You must be signed in to change notification settings - Fork 46.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Validate server markup against the DOM rather than string checksum #6618
Changes from all commits
b1a109b
91ad4b2
e1326b6
42c5c42
0cb995d
9b771bb
d9e4b79
352aa15
589a1b0
061867a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,221 @@ | ||
/** | ||
* 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 getInstanceDisplayName = require('getInstanceDisplayName'); | ||
var NativeNodes = require('NativeNodes'); | ||
|
||
// 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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Edit: nvm. I should read the whole thing before I comment. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there still an issue with using |
||
MarkupMismatchError.prototype.constructor = MarkupMismatchError; | ||
|
||
/** | ||
* Returns a user-readable description of a DOM node in the server-generated markup. | ||
*/ | ||
function getDescription(node) { | ||
switch (NativeNodes.getType(node)) { | ||
case NativeNodes.types.TEXT: | ||
return `A text node with text: '${abridgeContent(NativeNodes.getText(node))}'`; | ||
case NativeNodes.types.EMPTY: | ||
return 'An empty (or null) node'; | ||
case NativeNodes.types.ELEMENT: | ||
return `A <${node.tagName.toLowerCase()}> element with text: ` + | ||
`'${abridgeContent(NativeNodes.getText(node))}'`; | ||
default: | ||
return `An illegal DOM node with text: '${abridgeContent(NativeNodes.getText(node))}'`; | ||
} | ||
} | ||
/** | ||
* 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} node parent in the server markup | ||
* @param {ReactComponent} child The child instance in the client component tree | ||
* that wasn't present in the server markup. | ||
*/ | ||
function throwChildAddedError(node, child) { | ||
throw new MarkupMismatchError( | ||
`The client rendered a node that was not present in the server-generated markup.`, | ||
node, | ||
`<Nothing>`, | ||
`A child of type '${getInstanceDisplayName(child)}'`); | ||
} | ||
|
||
/** | ||
* 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) { | ||
throw new MarkupMismatchError( | ||
'The client failed to render a node that was present in the server-generated markup.', | ||
serverChild, | ||
getDescription(serverChild), | ||
'<Nothing>' | ||
); | ||
} | ||
|
||
/** | ||
* 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 client rendered a DOM element with a different HTML tag than that ' + | ||
'which was present in the server-generated markup.', | ||
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 client failed to render a '${attr}' attribute that was present in the server-generated markup.`, | ||
node, | ||
`${attr}=${value}`, | ||
`<No ${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 client rendered a '${attr}' attribute that was not present in the server-generated markup.`, | ||
node, | ||
`<No ${attr} value>`, | ||
`${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 client rendered a '${attr}' attribute with a different value than ` + | ||
`that which was present in the server-generated markup.`, | ||
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 client rendered a dangerouslySetInnerHTML value that was different ' + | ||
'than that which was present in the server-generated markup.', | ||
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( | ||
'The client rendered some text which was different than that which was ' + | ||
'present in the server-generated markup.', | ||
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<DOMNode>} 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 = getDescription(node); | ||
|
||
throw new MarkupMismatchError( | ||
'The client rendered one type of node, but a different type of node ' + | ||
'was present in the server-generated markup.', | ||
node, | ||
serverValue, | ||
clientComponentDesc); | ||
} | ||
|
||
/** | ||
* truncate text utility function. | ||
* @private | ||
* @param {String} text to truncate | ||
* @param {Number?} maximum length | ||
*/ | ||
function abridgeContent(text, maxLength = 30) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wasn't sure if there was a utility function that already did this; please let me know if there is. |
||
if (text.length < maxLength) { | ||
return text; | ||
} | ||
return text.substr(0, maxLength) + '...'; | ||
} | ||
|
||
module.exports = { | ||
error: MarkupMismatchError, | ||
throwChildAddedError, | ||
throwChildMissingError, | ||
throwNodeTypeMismatchError, | ||
throwAttributeMissingMismatchError, | ||
throwAttributeAddedMismatchError, | ||
throwAttributeChangedMismatchError, | ||
throwInnerHtmlMismatchError, | ||
throwTextMismatchError, | ||
throwComponentTypeMismatchError, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
/** | ||
* 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 NativeNodes | ||
*/ | ||
|
||
'use strict'; | ||
|
||
var TEXT_NODE_TYPE = 3; | ||
var COMMENT_NODE_TYPE = 8; | ||
var ELEMENT_NODE_TYPE = 1; | ||
|
||
var EMPTY_ARRAY = []; | ||
|
||
var types = { | ||
// UNKNOWN means this DOM node doesn't seem to correspond to any React component. | ||
UNKNOWN:0, | ||
// A single DOM element, which corresponds to a ReactDOMComponent. | ||
ELEMENT:1, | ||
// A DOM text node surrounded by react-text comment nodes. this is used in | ||
// the DOM when there is a ReactDOMTextComponent. | ||
TEXT:2, | ||
// A single comment DOM node, which corresponds to ReactDOMEmptyComponent. | ||
EMPTY:4, | ||
}; | ||
|
||
/** | ||
* Returns the type of React component that corresponds to the DOM node sent in. | ||
* @param {DOMNode} nativeNode a DOM node that corresponds to a React component in | ||
* the client rendering. Note that this is the **first** DOM node in the markup, but that | ||
* the React component may render more nodes after this node. For example, a text component | ||
* (ReactDOMTextComponent) renders two comment nodes (for empty text) or two comment nodes | ||
* and a text node (for non-empty text). In both cases, this method expects the first comment | ||
* node to be passed in. | ||
*/ | ||
function getType(nativeNode) { | ||
if (isTextComponentOpeningNode(nativeNode) | ||
&& nativeNode.nextSibling | ||
&& nativeNode.nextSibling.nodeType === TEXT_NODE_TYPE | ||
&& nativeNode.nextSibling.nextSibling | ||
&& isTextComponentClosingNode(nativeNode.nextSibling.nextSibling)) { | ||
|
||
return types.TEXT; | ||
} else if (isTextComponentOpeningNode(nativeNode) | ||
&& nativeNode.nextSibling | ||
&& isTextComponentClosingNode(nativeNode.nextSibling)) { | ||
|
||
return types.TEXT; | ||
} else { | ||
switch (nativeNode.nodeType) { | ||
case ELEMENT_NODE_TYPE: | ||
return types.ELEMENT; | ||
case COMMENT_NODE_TYPE: | ||
return types.EMPTY; | ||
} | ||
} | ||
return types.UNKNOWN; | ||
} | ||
|
||
/** | ||
* Returns the text content of the component represented by this domNode. | ||
* @param {DOMNode} nativeNode the first node of the component | ||
*/ | ||
function getText(nativeNode) { | ||
if (getType(nativeNode) === types.TEXT) { | ||
if (!nativeNode.nextSibling || | ||
(nativeNode.nextSibling | ||
&& nativeNode.nextSibling.nodeType === COMMENT_NODE_TYPE)) { | ||
return ''; | ||
} else { | ||
return nativeNode.nextSibling.textContent; | ||
} | ||
} | ||
return nativeNode.textContent; | ||
} | ||
|
||
/* | ||
* If nativeNode is the first DOM node used to represent a particular component, this | ||
* function returns the last DOM node to represent that component. Note that for components | ||
* that are represented by just one DOM node, this function returns its input. | ||
* @param {DOMNode} nativeNode the first DOM node representing a particular component | ||
*/ | ||
function getLastNode(nativeNode) { | ||
if (getType(nativeNode) === types.TEXT) { | ||
if (nativeNode.nextSibling | ||
&& nativeNode.nextSibling.nodeType === TEXT_NODE_TYPE) { | ||
return nativeNode.nextSibling.nextSibling; | ||
} | ||
return nativeNode.nextSibling; | ||
} | ||
return nativeNode; | ||
} | ||
|
||
function isTextComponentOpeningNode(node) { | ||
return (node.nodeType === COMMENT_NODE_TYPE | ||
&& node.nodeValue.lastIndexOf(' react-text', 0) === 0); | ||
} | ||
|
||
function isTextComponentClosingNode(node) { | ||
return (node.nodeType === COMMENT_NODE_TYPE | ||
&& node.nodeValue.lastIndexOf(' /react-text', 0) === 0); | ||
} | ||
|
||
/** | ||
* Returns the children of this dom node in a way that is easily consumable for | ||
* comparing to the component hierarchy. | ||
* Unfortunately, a node's DOM children don't correspond exactly to its component's | ||
* children. For example, text nodes in the component tree become 2 commment DOM | ||
* nodes and an optional text dom node. Empty components become a single comment DOM node. | ||
* | ||
* Returns an array of DOM nodes. Each item in | ||
* the returned array represents the first DOM node in the tree that corresponds | ||
* to a particular component in the client render tree. | ||
*/ | ||
function getNativeNodeChildren(parent) { | ||
var childNode = parent.firstChild; | ||
// special case: if there's just one child and it's text, then it's not a separate | ||
// component in the client render tree. Instead, it's just the text of a | ||
// ReactDOMComponent. In that case, return an empty child array. | ||
if (childNode && !childNode.nextSibling && childNode.nodeType === TEXT_NODE_TYPE) { | ||
return EMPTY_ARRAY; | ||
} | ||
|
||
var result = []; | ||
while (childNode) { | ||
result.push(childNode); | ||
if (childNode.nextSibling | ||
&& childNode.nextSibling.nextSibling | ||
&& isTextComponentOpeningNode(childNode) | ||
&& childNode.nextSibling.nodeType === TEXT_NODE_TYPE | ||
&& isTextComponentClosingNode(childNode.nextSibling.nextSibling)) { | ||
// text component with content: two comment nodes surrounding a text node. | ||
childNode = childNode.nextSibling.nextSibling.nextSibling; | ||
} else if (childNode.nextSibling | ||
&& isTextComponentOpeningNode(childNode) | ||
&& isTextComponentClosingNode(childNode.nextSibling)) { | ||
// text component with no content; two comment nodes next to each other. | ||
childNode = childNode.nextSibling.nextSibling; | ||
} else { | ||
// a regular node. | ||
childNode = childNode.nextSibling; | ||
} | ||
} | ||
return result; | ||
} | ||
|
||
module.exports = { | ||
getLastNode, | ||
getNativeNodeChildren, | ||
getText, | ||
getType, | ||
types, | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will this reliably grab stack information across all browsers?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From the linked document on developer.mozilla.org in the comments:
I can do some research into other browsers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reading further on this at this MDN article and this MSDN article, it seems to me that this will grab a stack for all browsers where a stack is available. Stack is completely non-standard, so I can't guarantee that it will work absolutely everywhere.
It should be fine on platforms where there is no stack property.
Note also that every place in the current code where MismatchError is thrown, it is caught without looking at the stack.
Given all that, I'm going to leave the code as is. Let me know if you disagree, and thanks for the comment!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You might need to throw it to guarantee that the stack trace is serialized: unexpectedjs/unexpected@5dcd441
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the feedback, @Munter!
I'll look at this again and patch it up if we decide to move forward with this PR.