Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion packages/react-dom-bindings/src/client/ReactDOMComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,16 @@ function warnForPropDifference(
if (normalizedServerValue === normalizedClientValue) {
return;
}
// Also check with attribute whitespace normalization: browsers
// normalize \t, \n, \r to spaces in HTML attribute values.
if (
typeof normalizedServerValue === 'string' &&
typeof normalizedClientValue === 'string' &&
normalizeAttributeValueForComparison(normalizedServerValue) ===
normalizeAttributeValueForComparison(normalizedClientValue)
) {
return;
}

serverDifferences[propName] = serverValue;
}
Expand Down Expand Up @@ -343,6 +353,12 @@ function normalizeHTML(parent: Element, html: string) {
const NORMALIZE_NEWLINES_REGEX = /\r\n?/g;
const NORMALIZE_NULL_AND_REPLACEMENT_REGEX = /\u0000|\uFFFD/g;

// Browsers normalize tab, newline, and carriage return characters to spaces
// when parsing HTML attribute values. We normalize these before comparing
// attribute values during hydration to avoid false mismatches.
// See: https://html.spec.whatwg.org/multipage/parsing.html#attribute-value-(double-quoted)-state
const NORMALIZE_ATTRIBUTE_WHITESPACE_REGEX = /[\t\n\r]/g;

function normalizeMarkupForTextOrAttribute(markup: mixed): string {
if (__DEV__) {
checkHtmlStringCoercion(markup);
Expand All @@ -353,6 +369,10 @@ function normalizeMarkupForTextOrAttribute(markup: mixed): string {
.replace(NORMALIZE_NULL_AND_REPLACEMENT_REGEX, '');
}

function normalizeAttributeValueForComparison(value: string): string {
return value.replace(NORMALIZE_ATTRIBUTE_WHITESPACE_REGEX, ' ');
}

function checkForUnmatchedText(
serverText: string,
clientText: string | number | bigint,
Expand Down Expand Up @@ -2119,7 +2139,17 @@ function hydrateAttribute(
if (__DEV__) {
checkAttributeStringCoercion(value, propKey);
}
if (serverValue === '' + value) {
const coercedValue = '' + value;
if (serverValue === coercedValue) {
return;
}
// Browsers may normalize whitespace characters (tab, newline,
// carriage return) to spaces in attribute values. If the only
// difference is this normalization, treat it as a match.
if (
normalizeAttributeValueForComparison(serverValue) ===
normalizeAttributeValueForComparison(coercedValue)
) {
return;
}
}
Expand Down
44 changes: 44 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,50 @@ describe('ReactDOMServerHydration', () => {
});
});

describe('attribute whitespace normalization', () => {
// @gate __DEV__
it('does not warn when attribute values differ only by whitespace normalization', () => {
function Mismatch({isClient}) {
return (
<div className="parent">
<main
className={isClient ? 'foo bar baz' : 'foo\nbar\nbaz'}
/>
</div>
);
}
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`[]`);
});

// @gate __DEV__
it('does not warn for tab and carriage return normalization', () => {
function Mismatch({isClient}) {
return (
<div className="parent">
<main
className={isClient ? 'foo bar baz' : 'foo\tbar\r\nbaz'}
/>
</div>
);
}
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`[]`);
});

// @gate __DEV__
it('does not warn for title attribute whitespace normalization', () => {
function Mismatch({isClient}) {
return (
<div>
<span
title={isClient ? 'line1 line2' : 'line1\nline2'}
/>
</div>
);
}
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`[]`);
});
});

describe('extra nodes on the client', () => {
describe('extra elements on the client', () => {
// @gate __DEV__
Expand Down