diff --git a/src/renderers/dom/fiber/ReactDOMFiberComponent.js b/src/renderers/dom/fiber/ReactDOMFiberComponent.js index 41c01404f8ea3..8cb1f608ba574 100644 --- a/src/renderers/dom/fiber/ReactDOMFiberComponent.js +++ b/src/renderers/dom/fiber/ReactDOMFiberComponent.js @@ -76,16 +76,41 @@ if (__DEV__) { validateUnknownProperties(type, props); }; - var warnForTextDifference = function(serverText: string, clientText: string) { + // HTML parsing normalizes CR and CRLF to LF. + // It also can turn \u0000 into \uFFFD inside attributes. + // https://www.w3.org/TR/html5/single-page.html#preprocessing-the-input-stream + // If we have a mismatch, it might be caused by that. + // We will still patch up in this case but not fire the warning. + var NORMALIZE_NEWLINES_REGEX = /\r\n?/g; + var NORMALIZE_NULL_AND_REPLACEMENT_REGEX = /\u0000|\uFFFD/g; + + var normalizeMarkupForTextOrAttribute = function(markup: mixed): string { + const markupString = typeof markup === 'string' + ? markup + : '' + (markup: any); + return markupString + .replace(NORMALIZE_NEWLINES_REGEX, '\n') + .replace(NORMALIZE_NULL_AND_REPLACEMENT_REGEX, ''); + }; + + var warnForTextDifference = function( + serverText: string, + clientText: string | number, + ) { if (didWarnInvalidHydration) { return; } + const normalizedClientText = normalizeMarkupForTextOrAttribute(clientText); + const normalizedServerText = normalizeMarkupForTextOrAttribute(serverText); + if (normalizedServerText === normalizedClientText) { + return; + } didWarnInvalidHydration = true; warning( false, 'Text content did not match. Server: "%s" Client: "%s"', - serverText, - clientText, + normalizedServerText, + normalizedClientText, ); }; @@ -97,13 +122,22 @@ if (__DEV__) { if (didWarnInvalidHydration) { return; } + const normalizedClientValue = normalizeMarkupForTextOrAttribute( + clientValue, + ); + const normalizedServerValue = normalizeMarkupForTextOrAttribute( + serverValue, + ); + if (normalizedServerValue === normalizedClientValue) { + return; + } didWarnInvalidHydration = true; warning( false, 'Prop `%s` did not match. Server: %s Client: %s', propName, - JSON.stringify(serverValue), - JSON.stringify(clientValue), + JSON.stringify(normalizedServerValue), + JSON.stringify(normalizedClientValue), ); }; diff --git a/src/renderers/dom/shared/__tests__/ReactDOMServerIntegration-test.js b/src/renderers/dom/shared/__tests__/ReactDOMServerIntegration-test.js index 642e99a0e06db..61fdb2ccb61bc 100644 --- a/src/renderers/dom/shared/__tests__/ReactDOMServerIntegration-test.js +++ b/src/renderers/dom/shared/__tests__/ReactDOMServerIntegration-test.js @@ -1501,6 +1501,82 @@ describe('ReactDOMServerIntegration', () => { }); }); + describe('carriage return and null character', () => { + // HTML parsing normalizes CR and CRLF to LF. + // It also ignores null character. + // https://www.w3.org/TR/html5/single-page.html#preprocessing-the-input-stream + // If we have a mismatch, it might be caused by that (and should not be reported). + // We won't be patching up in this case as that matches our past behavior. + + itRenders( + 'an element with one text child with special characters', + async render => { + const e = await render(
{'foo\rbar\r\nbaz\nqux\u0000'}
); + if (render === serverRender || render === streamRender) { + expect(e.childNodes.length).toBe(1); + // Everything becomes LF when parsed from server HTML. + // Null character is ignored. + expectNode(e.childNodes[0], TEXT_NODE_TYPE, 'foo\nbar\nbaz\nqux'); + } else { + expect(e.childNodes.length).toBe(1); + // Client rendering (or hydration) uses JS value with CR. + // Null character stays. + expectNode( + e.childNodes[0], + TEXT_NODE_TYPE, + 'foo\rbar\r\nbaz\nqux\u0000', + ); + } + }, + ); + + itRenders( + 'an element with two text children with special characters', + async render => { + const e = await render(
{'foo\rbar'}{'\r\nbaz\nqux\u0000'}
); + if (render === serverRender || render === streamRender) { + // We have three nodes because there is a comment between them. + expect(e.childNodes.length).toBe(3); + // Everything becomes LF when parsed from server HTML. + // Null character is ignored. + expectNode(e.childNodes[0], TEXT_NODE_TYPE, 'foo\nbar'); + expectNode(e.childNodes[2], TEXT_NODE_TYPE, '\nbaz\nqux'); + } else if (render === clientRenderOnServerString) { + // We have three nodes because there is a comment between them. + expect(e.childNodes.length).toBe(3); + // Hydration uses JS value with CR and null character. + expectNode(e.childNodes[0], TEXT_NODE_TYPE, 'foo\rbar'); + expectNode(e.childNodes[2], TEXT_NODE_TYPE, '\r\nbaz\nqux\u0000'); + } else { + expect(e.childNodes.length).toBe(2); + // Client rendering uses JS value with CR and null character. + expectNode(e.childNodes[0], TEXT_NODE_TYPE, 'foo\rbar'); + expectNode(e.childNodes[1], TEXT_NODE_TYPE, '\r\nbaz\nqux\u0000'); + } + }, + ); + + itRenders( + 'an element with an attribute value with special characters', + async render => { + const e = await render(); + if ( + render === serverRender || + render === streamRender || + render === clientRenderOnServerString + ) { + // Everything becomes LF when parsed from server HTML. + // Null character in an attribute becomes the replacement character. + // Hydration also ends up with LF because we don't patch up attributes. + expect(e.title).toBe('foo\nbar\nbaz\nqux\uFFFD'); + } else { + // Client rendering uses JS value with CR and null character. + expect(e.title).toBe('foo\rbar\r\nbaz\nqux\u0000'); + } + }, + ); + }); + describe('components that throw errors', function() { itThrowsWhenRendering( 'a function returning undefined',