diff --git a/fixtures/ssr/src/components/Page.js b/fixtures/ssr/src/components/Page.js index 0284b6b39d811..806d1115c8207 100644 --- a/fixtures/ssr/src/components/Page.js +++ b/fixtures/ssr/src/components/Page.js @@ -15,6 +15,9 @@ export default class Page extends Component { ); return (
+

+ A random number: {Math.random()} +

{!this.state.active ? link : 'Thanks!'}

diff --git a/fixtures/ssr/src/index.js b/fixtures/ssr/src/index.js index ca551c8dadc9a..e4364cfbea28d 100644 --- a/fixtures/ssr/src/index.js +++ b/fixtures/ssr/src/index.js @@ -1,6 +1,6 @@ import React from 'react'; -import {render} from 'react-dom'; +import {hydrate} from 'react-dom'; import App from './components/App'; -render(, document); +hydrate(, document); diff --git a/src/renderers/dom/fiber/ReactDOMFiberComponent.js b/src/renderers/dom/fiber/ReactDOMFiberComponent.js index 8cb1f608ba574..07a84c242ad28 100644 --- a/src/renderers/dom/fiber/ReactDOMFiberComponent.js +++ b/src/renderers/dom/fiber/ReactDOMFiberComponent.js @@ -53,6 +53,7 @@ var registrationNameModules = EventPluginRegistry.registrationNameModules; var DANGEROUSLY_SET_INNER_HTML = 'dangerouslySetInnerHTML'; var SUPPRESS_CONTENT_EDITABLE_WARNING = 'suppressContentEditableWarning'; +var SUPPRESS_HYDRATION_WARNING = 'suppressHydrationWarning'; var CHILDREN = 'children'; var STYLE = 'style'; var HTML = '__html'; @@ -273,7 +274,10 @@ function setInitialDOMProperties( } else if (typeof nextProp === 'number') { setTextContent(domElement, '' + nextProp); } - } else if (propKey === SUPPRESS_CONTENT_EDITABLE_WARNING) { + } else if ( + propKey === SUPPRESS_CONTENT_EDITABLE_WARNING || + propKey === SUPPRESS_HYDRATION_WARNING + ) { // Noop } else if (registrationNameModules.hasOwnProperty(propKey)) { if (nextProp != null) { @@ -664,7 +668,10 @@ var ReactDOMFiberComponent = { propKey === CHILDREN ) { // Noop. This is handled by the clear text mechanism. - } else if (propKey === SUPPRESS_CONTENT_EDITABLE_WARNING) { + } else if ( + propKey === SUPPRESS_CONTENT_EDITABLE_WARNING || + propKey === SUPPRESS_HYDRATION_WARNING + ) { // Noop } else if (registrationNameModules.hasOwnProperty(propKey)) { // This is a special case. If any listener updates we need to ensure @@ -750,7 +757,10 @@ var ReactDOMFiberComponent = { ) { (updatePayload = updatePayload || []).push(propKey, '' + nextProp); } - } else if (propKey === SUPPRESS_CONTENT_EDITABLE_WARNING) { + } else if ( + propKey === SUPPRESS_CONTENT_EDITABLE_WARNING || + propKey === SUPPRESS_HYDRATION_WARNING + ) { // Noop } else if (registrationNameModules.hasOwnProperty(propKey)) { if (nextProp != null) { @@ -828,6 +838,8 @@ var ReactDOMFiberComponent = { rootContainerElement: Element | Document, ): null | Array { if (__DEV__) { + var suppressHydrationWarning = + rawProps[SUPPRESS_HYDRATION_WARNING] === true; var isCustomComponentTag = isCustomComponent(tag, rawProps); validatePropertiesInDevelopment(tag, rawProps); if (isCustomComponentTag && !didWarnShadyDOM && domElement.shadyRoot) { @@ -986,14 +998,14 @@ var ReactDOMFiberComponent = { // TODO: Should we use domElement.firstChild.nodeValue to compare? if (typeof nextProp === 'string') { if (domElement.textContent !== nextProp) { - if (__DEV__) { + if (__DEV__ && !suppressHydrationWarning) { warnForTextDifference(domElement.textContent, nextProp); } updatePayload = [CHILDREN, nextProp]; } } else if (typeof nextProp === 'number') { if (domElement.textContent !== '' + nextProp) { - if (__DEV__) { + if (__DEV__ && !suppressHydrationWarning) { warnForTextDifference(domElement.textContent, nextProp); } updatePayload = [CHILDREN, '' + nextProp]; @@ -1010,8 +1022,11 @@ var ReactDOMFiberComponent = { // Validate that the properties correspond to their expected values. var serverValue; var propertyInfo; - if ( + if (suppressHydrationWarning) { + // Don't bother comparing. We're ignoring all these warnings. + } else if ( propKey === SUPPRESS_CONTENT_EDITABLE_WARNING || + propKey === SUPPRESS_HYDRATION_WARNING || // Controlled attributes are not validated // TODO: Only ignore them on controlled tags. propKey === 'value' || @@ -1085,7 +1100,7 @@ var ReactDOMFiberComponent = { if (__DEV__) { // $FlowFixMe - Should be inferred as not undefined. - if (extraAttributeNames.size > 0) { + if (extraAttributeNames.size > 0 && !suppressHydrationWarning) { // $FlowFixMe - Should be inferred as not undefined. warnForExtraAttributes(extraAttributeNames); } @@ -1125,12 +1140,13 @@ var ReactDOMFiberComponent = { diffHydratedText(textNode: Text, text: string): boolean { const isDifferent = textNode.nodeValue !== text; + return isDifferent; + }, + + warnForUnmatchedText(textNode: Text, text: string) { if (__DEV__) { - if (isDifferent) { - warnForTextDifference(textNode.nodeValue, text); - } + warnForTextDifference(textNode.nodeValue, text); } - return isDifferent; }, warnForDeletedHydratableElement( diff --git a/src/renderers/dom/fiber/ReactDOMFiberEntry.js b/src/renderers/dom/fiber/ReactDOMFiberEntry.js index 0e0559d47a1d7..d0dd466bfb4fb 100644 --- a/src/renderers/dom/fiber/ReactDOMFiberEntry.js +++ b/src/renderers/dom/fiber/ReactDOMFiberEntry.js @@ -50,6 +50,7 @@ var { updateProperties, diffHydratedProperties, diffHydratedText, + warnForUnmatchedText, warnForDeletedHydratableElement, warnForDeletedHydratableText, warnForInsertedHydratedElement, @@ -58,6 +59,7 @@ var { var {precacheFiberNode, updateFiberProps} = ReactDOMComponentTree; if (__DEV__) { + var SUPPRESS_HYDRATION_WARNING = 'suppressHydrationWarning'; var lowPriorityWarning = require('lowPriorityWarning'); var warning = require('fbjs/lib/warning'); var validateDOMNesting = require('validateDOMNesting'); @@ -99,6 +101,7 @@ type Props = { autoFocus?: boolean, children?: mixed, hidden?: boolean, + suppressHydrationWarning?: boolean, }; type Instance = Element; type TextInstance = Text; @@ -523,30 +526,96 @@ var DOMRenderer = ReactFiberReconciler({ return diffHydratedText(textInstance, text); }, + didNotMatchHydratedContainerTextInstance( + parentContainer: Container, + textInstance: TextInstance, + text: string, + ) { + if (__DEV__) { + warnForUnmatchedText(textInstance, text); + } + }, + + didNotMatchHydratedTextInstance( + parentType: string, + parentProps: Props, + parentInstance: Instance, + textInstance: TextInstance, + text: string, + ) { + if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) { + warnForUnmatchedText(textInstance, text); + } + }, + + didNotHydrateContainerInstance( + parentContainer: Container, + instance: Instance | TextInstance, + ) { + if (__DEV__) { + if (instance.nodeType === 1) { + warnForDeletedHydratableElement(parentContainer, (instance: any)); + } else { + warnForDeletedHydratableText(parentContainer, (instance: any)); + } + } + }, + didNotHydrateInstance( - parentInstance: Instance | Container, + parentType: string, + parentProps: Props, + parentInstance: Instance, instance: Instance | TextInstance, ) { - if (instance.nodeType === 1) { - warnForDeletedHydratableElement(parentInstance, (instance: any)); - } else { - warnForDeletedHydratableText(parentInstance, (instance: any)); + if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) { + if (instance.nodeType === 1) { + warnForDeletedHydratableElement(parentInstance, (instance: any)); + } else { + warnForDeletedHydratableText(parentInstance, (instance: any)); + } + } + }, + + didNotFindHydratableContainerInstance( + parentContainer: Container, + type: string, + props: Props, + ) { + if (__DEV__) { + warnForInsertedHydratedElement(parentContainer, type, props); + } + }, + + didNotFindHydratableContainerTextInstance( + parentContainer: Container, + text: string, + ) { + if (__DEV__) { + warnForInsertedHydratedText(parentContainer, text); } }, didNotFindHydratableInstance( - parentInstance: Instance | Container, + parentType: string, + parentProps: Props, + parentInstance: Instance, type: string, props: Props, ) { - warnForInsertedHydratedElement(parentInstance, type, props); + if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) { + warnForInsertedHydratedElement(parentInstance, type, props); + } }, didNotFindHydratableTextInstance( - parentInstance: Instance | Container, + parentType: string, + parentProps: Props, + parentInstance: Instance, text: string, ) { - warnForInsertedHydratedText(parentInstance, text); + if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) { + warnForInsertedHydratedText(parentInstance, text); + } }, scheduleDeferredCallback: ReactDOMFrameScheduling.rIC, diff --git a/src/renderers/dom/shared/DOMProperty.js b/src/renderers/dom/shared/DOMProperty.js index 6ca4c3cbc4914..197b57971baad 100644 --- a/src/renderers/dom/shared/DOMProperty.js +++ b/src/renderers/dom/shared/DOMProperty.js @@ -21,6 +21,7 @@ var RESERVED_PROPS = { defaultChecked: true, innerHTML: true, suppressContentEditableWarning: true, + suppressHydrationWarning: true, style: true, }; diff --git a/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js b/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js index 8c8c9ea1e41ef..b140a31301993 100644 --- a/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js +++ b/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js @@ -318,6 +318,7 @@ describe('ReactDOMComponent', () => { , container, ); @@ -325,11 +326,15 @@ describe('ReactDOMComponent', () => { expect( container.firstChild.hasAttribute('suppressContentEditableWarning'), ).toBe(false); + expect( + container.firstChild.hasAttribute('suppressHydrationWarning'), + ).toBe(false); ReactDOM.render( , container, ); @@ -337,6 +342,9 @@ describe('ReactDOMComponent', () => { expect( container.firstChild.hasAttribute('suppressContentEditableWarning'), ).toBe(false); + expect( + container.firstChild.hasAttribute('suppressHydrationWarning'), + ).toBe(false); }); it('should skip dangerouslySetInnerHTML on web components', () => { diff --git a/src/renderers/dom/shared/__tests__/ReactDOMServerIntegration-test.js b/src/renderers/dom/shared/__tests__/ReactDOMServerIntegration-test.js index faa22ff76ccfd..f4949a77ce596 100644 --- a/src/renderers/dom/shared/__tests__/ReactDOMServerIntegration-test.js +++ b/src/renderers/dom/shared/__tests__/ReactDOMServerIntegration-test.js @@ -725,6 +725,16 @@ describe('ReactDOMServerIntegration', () => { ); expect(e.getAttribute('dangerouslySetInnerHTML')).toBe(null); }); + + itRenders('no suppressContentEditableWarning attribute', async render => { + const e = await render(
); + expect(e.getAttribute('suppressContentEditableWarning')).toBe(null); + }); + + itRenders('no suppressHydrationWarning attribute', async render => { + const e = await render(); + expect(e.getAttribute('suppressHydrationWarning')).toBe(null); + }); }); describe('inline styles', function() { @@ -2623,6 +2633,36 @@ describe('ReactDOMServerIntegration', () => { it('should error reconnecting different attribute values', () => expectMarkupMismatch(
,
)); + + it('can explicitly ignore errors reconnecting different element types of children', () => + expectMarkupMatch( +
, +
, + )); + + it('can explicitly ignore errors reconnecting missing attributes', () => + expectMarkupMatch( +
, +
, + )); + + it('can explicitly ignore errors reconnecting added attributes', () => + expectMarkupMatch( +
, +
, + )); + + it('can explicitly ignore errors reconnecting different attribute values', () => + expectMarkupMatch( +
, +
, + )); + + it('can not deeply ignore errors reconnecting different attribute values', () => + expectMarkupMismatch( +
, +
, + )); }); describe('inline styles', function() { @@ -2661,6 +2701,18 @@ describe('ReactDOMServerIntegration', () => {
,
, )); + + it('can explicitly ignore errors reconnecting added style values', () => + expectMarkupMatch( +
, +
, + )); + + it('can explicitly ignore reconnecting different style values', () => + expectMarkupMatch( +
, +
, + )); }); describe('text nodes', function() { @@ -2681,6 +2733,18 @@ describe('ReactDOMServerIntegration', () => {
{'Text1'}{'Text2'}
,
{'Text1'}{'Text3'}
, )); + + it('can explicitly ignore reconnecting different text', () => + expectMarkupMatch( +
Text
, +
Other Text
, + )); + + it('can explicitly ignore reconnecting different text in two code blocks', () => + expectMarkupMatch( +
{'Text1'}{'Text2'}
, +
{'Text1'}{'Text3'}
, + )); }); describe('element trees and children', function() { @@ -2731,6 +2795,30 @@ describe('ReactDOMServerIntegration', () => { it('can distinguish an empty component from an empty text component', () => expectMarkupMatch(
,
{''}
)); + + it('can explicitly ignore reconnecting more children', () => + expectMarkupMatch( +
, +
, + )); + + it('can explicitly ignore reconnecting fewer children', () => + expectMarkupMatch( +
, +
, + )); + + it('can explicitly ignore reconnecting reordered children', () => + expectMarkupMatch( +
, +
, + )); + + it('can not deeply ignore reconnecting reordered children', () => + expectMarkupMismatch( +
, +
, + )); }); // Markup Mismatches: misc @@ -2739,5 +2827,14 @@ describe('ReactDOMServerIntegration', () => {
"}} />,
"}} />, )); + + it('can explicitly ignore reconnecting a div with different dangerouslySetInnerHTML', () => + expectMarkupMatch( +
"}} />, +
"}} + suppressHydrationWarning={true} + />, + )); }); }); diff --git a/src/renderers/dom/shared/hooks/possibleStandardNames.js b/src/renderers/dom/shared/hooks/possibleStandardNames.js index 236de2e0e9aef..0349d4ce4e0b7 100644 --- a/src/renderers/dom/shared/hooks/possibleStandardNames.js +++ b/src/renderers/dom/shared/hooks/possibleStandardNames.js @@ -401,6 +401,7 @@ var possibleStandardNames = { strokeopacity: 'strokeOpacity', 'stroke-opacity': 'strokeOpacity', suppresscontenteditablewarning: 'suppressContentEditableWarning', + suppresshydrationwarning: 'suppressHydrationWarning', surfacescale: 'surfaceScale', systemlanguage: 'systemLanguage', tablevalues: 'tableValues', diff --git a/src/renderers/shared/fiber/ReactFiberHydrationContext.js b/src/renderers/shared/fiber/ReactFiberHydrationContext.js index dee6f885200b1..879bee732eb53 100644 --- a/src/renderers/shared/fiber/ReactFiberHydrationContext.js +++ b/src/renderers/shared/fiber/ReactFiberHydrationContext.js @@ -44,7 +44,13 @@ module.exports = function( getFirstHydratableChild, hydrateInstance, hydrateTextInstance, + didNotMatchHydratedContainerTextInstance, + didNotMatchHydratedTextInstance, + didNotHydrateContainerInstance, didNotHydrateInstance, + // TODO: These are currently unused, see below. + // didNotFindHydratableContainerInstance, + // didNotFindHydratableContainerTextInstance, didNotFindHydratableInstance, didNotFindHydratableTextInstance, } = config; @@ -57,7 +63,12 @@ module.exports = function( getFirstHydratableChild && hydrateInstance && hydrateTextInstance && + didNotMatchHydratedContainerTextInstance && + didNotMatchHydratedTextInstance && + didNotHydrateContainerInstance && didNotHydrateInstance && + // didNotFindHydratableContainerInstance && + // didNotFindHydratableContainerTextInstance && didNotFindHydratableInstance && didNotFindHydratableTextInstance) ) { @@ -105,10 +116,18 @@ module.exports = function( if (__DEV__) { switch (returnFiber.tag) { case HostRoot: - didNotHydrateInstance(returnFiber.stateNode.containerInfo, instance); + didNotHydrateContainerInstance( + returnFiber.stateNode.containerInfo, + instance, + ); break; case HostComponent: - didNotHydrateInstance(returnFiber.stateNode, instance); + didNotHydrateInstance( + returnFiber.type, + returnFiber.memoizedProps, + returnFiber.stateNode, + instance, + ); break; } } @@ -134,32 +153,57 @@ module.exports = function( function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { fiber.effectTag |= Placement; if (__DEV__) { - var parentInstance; switch (returnFiber.tag) { // TODO: Currently we don't warn for insertions into the root because // we always insert into the root in the non-hydrating case. We just // delete the existing content. Reenable this once we have a better // strategy for determining if we're hydrating or not. - // case HostRoot: - // parentInstance = returnFiber.stateNode.containerInfo; + // case HostRoot: { + // const parentContainer = returnFiber.stateNode.containerInfo; + // switch (fiber.tag) { + // case HostComponent: + // const type = fiber.type; + // const props = fiber.pendingProps; + // didNotFindHydratableContainerInstance(parentContainer, type, props); + // break; + // case HostText: + // const text = fiber.pendingProps; + // didNotFindHydratableContainerTextInstance(parentContainer, text); + // break; + // } // break; - case HostComponent: - parentInstance = returnFiber.stateNode; + // } + case HostComponent: { + const parentType = returnFiber.type; + const parentProps = returnFiber.memoizedProps; + const parentInstance = returnFiber.stateNode; + switch (fiber.tag) { + case HostComponent: + const type = fiber.type; + const props = fiber.pendingProps; + didNotFindHydratableInstance( + parentType, + parentProps, + parentInstance, + type, + props, + ); + break; + case HostText: + const text = fiber.pendingProps; + didNotFindHydratableTextInstance( + parentType, + parentProps, + parentInstance, + text, + ); + break; + } break; + } default: return; } - switch (fiber.tag) { - case HostComponent: - const type = fiber.type; - const props = fiber.pendingProps; - didNotFindHydratableInstance(parentInstance, type, props); - break; - case HostText: - const text = fiber.pendingProps; - didNotFindHydratableTextInstance(parentInstance, text); - break; - } } } @@ -243,11 +287,41 @@ module.exports = function( function prepareToHydrateHostTextInstance(fiber: Fiber): boolean { const textInstance: TI = fiber.stateNode; - const shouldUpdate = hydrateTextInstance( - textInstance, - fiber.memoizedProps, - fiber, - ); + const textContent: string = fiber.memoizedProps; + const shouldUpdate = hydrateTextInstance(textInstance, textContent, fiber); + if (__DEV__) { + if (shouldUpdate) { + // We assume that prepareToHydrateHostTextInstance is called in a context where the + // hydration parent is the parent host component of this host text. + const returnFiber = hydrationParentFiber; + if (returnFiber !== null) { + switch (returnFiber.tag) { + case HostRoot: { + const parentContainer = returnFiber.stateNode.containerInfo; + didNotMatchHydratedContainerTextInstance( + parentContainer, + textInstance, + textContent, + ); + break; + } + case HostComponent: { + const parentType = returnFiber.type; + const parentProps = returnFiber.memoizedProps; + const parentInstance = returnFiber.stateNode; + didNotMatchHydratedTextInstance( + parentType, + parentProps, + parentInstance, + textInstance, + textContent, + ); + break; + } + } + } + } + } return shouldUpdate; } diff --git a/src/renderers/shared/fiber/ReactFiberReconciler.js b/src/renderers/shared/fiber/ReactFiberReconciler.js index eafa87add0c78..0d7a76021a982 100644 --- a/src/renderers/shared/fiber/ReactFiberReconciler.js +++ b/src/renderers/shared/fiber/ReactFiberReconciler.js @@ -139,14 +139,48 @@ export type HostConfig = { text: string, internalInstanceHandle: OpaqueHandle, ) => boolean, - didNotHydrateInstance?: (parentInstance: I | C, instance: I | TI) => void, + didNotMatchHydratedContainerTextInstance?: ( + parentContainer: C, + textInstance: TI, + text: string, + ) => void, + didNotMatchHydratedTextInstance?: ( + parentType: T, + parentProps: P, + parentInstance: I, + textInstance: TI, + text: string, + ) => void, + didNotHydrateContainerInstance?: ( + parentContainer: C, + instance: I | TI, + ) => void, + didNotHydrateInstance?: ( + parentType: T, + parentProps: P, + parentInstance: I, + instance: I | TI, + ) => void, + didNotFindHydratableContainerInstance?: ( + parentContainer: C, + type: T, + props: P, + ) => void, + didNotFindHydratableContainerTextInstance?: ( + parentContainer: C, + text: string, + ) => void, didNotFindHydratableInstance?: ( - parentInstance: I | C, + parentType: T, + parentProps: P, + parentInstance: I, type: T, props: P, ) => void, didNotFindHydratableTextInstance?: ( - parentInstance: I | C, + parentType: T, + parentProps: P, + parentInstance: I, text: string, ) => void, diff --git a/src/renderers/shared/server/ReactPartialRenderer.js b/src/renderers/shared/server/ReactPartialRenderer.js index facf1c2bc88b9..9f7e41ffd1f45 100644 --- a/src/renderers/shared/server/ReactPartialRenderer.js +++ b/src/renderers/shared/server/ReactPartialRenderer.js @@ -251,6 +251,7 @@ var RESERVED_PROPS = { children: null, dangerouslySetInnerHTML: null, suppressContentEditableWarning: null, + suppressHydrationWarning: null, }; function createOpenTagMarkup(