From 4131af3e4bf52f3a003537ec95a1655147c81270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 6 Oct 2017 19:50:01 -0700 Subject: [PATCH] Ignore SSR warning using explicit suppressHydrationWarning option (#11126) * Pass parent type and props to insert/delete hydration warning hooks For this to work, we need to split the API into a container and normal version. Since the root doesn't have a type nor props. * Ignore SSR warning using explicit suppressHydrationWarning option This lets you ignore the warning on a single element and its direct child content. This is useful for simple fields that you're expecting to fail such as time stamps. Note that this still won't patch up such content so it'll remain inconsistent. It's also not suitable for nested complex content that may change. * Suppress warning of inserted/deleted direct children * Add fixture testing hydration warning Also fixing the render->hydrate API change in the fixture * Add hooks when text hydration doesn't match up The purpose of these hooks is to pass the parent context to them. I don't want to do that in the normal hydrateTextInstance hooks since this is only used in DEV. This is also in line with what happens if there is no text instance at all and we invoke didNotFindHydratableTextInstance. * Move mismatch text hydration warning to the new hooks This lets us ignore this call when we have parent props available and the suppression flag is set. --- fixtures/ssr/src/components/Page.js | 3 + fixtures/ssr/src/index.js | 4 +- .../dom/fiber/ReactDOMFiberComponent.js | 38 ++++-- src/renderers/dom/fiber/ReactDOMFiberEntry.js | 87 +++++++++++-- src/renderers/dom/shared/DOMProperty.js | 1 + .../__tests__/ReactDOMComponent-test.js | 8 ++ .../ReactDOMServerIntegration-test.js | 97 ++++++++++++++ .../dom/shared/hooks/possibleStandardNames.js | 1 + .../fiber/ReactFiberHydrationContext.js | 120 ++++++++++++++---- .../shared/fiber/ReactFiberReconciler.js | 40 +++++- .../shared/server/ReactPartialRenderer.js | 1 + 11 files changed, 352 insertions(+), 48 deletions(-) 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 aeed6ac9b69e9..4876d88f4607b 100644 --- a/src/renderers/shared/fiber/ReactFiberReconciler.js +++ b/src/renderers/shared/fiber/ReactFiberReconciler.js @@ -140,14 +140,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(