diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 549b279f1da50..cfb80809cb45a 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -230,6 +230,13 @@ function warnForPropDifference( if (normalizedServerValue === normalizedClientValue) { return; } + if (propName === 'class' || propName === 'className') { + const normalizedClientClass = normalizeClassForHydration(clientValue); + const normalizedServerClass = normalizeClassForHydration(serverValue); + if (normalizedServerClass === normalizedClientClass) { + return; + } + } serverDifferences[propName] = serverValue; } @@ -300,6 +307,12 @@ function normalizeHTML(parent: Element, html: string) { const NORMALIZE_NEWLINES_REGEX = /\r\n?/g; const NORMALIZE_NULL_AND_REPLACEMENT_REGEX = /\u0000|\uFFFD/g; +// HTML: `class` is a space-separated token list split on ASCII whitespace +// (TAB U+0009, LF U+000A, FF U+000C, CR U+000D, SPACE U+0020). +// Specs: https://infra.spec.whatwg.org/#ascii-whitespace +// https://html.spec.whatwg.org/multipage/dom.html#global-attributes +const HTML_SPACE_CLASS_SEPARATOR = /[ \t\n\f\r]+/; + function normalizeMarkupForTextOrAttribute(markup: mixed): string { if (__DEV__) { checkHtmlStringCoercion(markup); @@ -310,6 +323,14 @@ function normalizeMarkupForTextOrAttribute(markup: mixed): string { .replace(NORMALIZE_NULL_AND_REPLACEMENT_REGEX, ''); } +function normalizeClassForHydration(markup: mixed): string { + const s = normalizeMarkupForTextOrAttribute(markup); + const tokens = s.trim().split(HTML_SPACE_CLASS_SEPARATOR).filter(Boolean); + const unique = Array.from(new Set(tokens)); + unique.sort(); + return unique.join(' '); +} + function checkForUnmatchedText( serverText: string, clientText: string | number | bigint, diff --git a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js index 5ea0ceeac4545..800e539936f4e 100644 --- a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js @@ -333,6 +333,35 @@ describe('ReactDOMServerHydration', () => { ]); }); + it('should not warn when class differs only by whitespace/order/duplicates', async () => { + const element = document.createElement('div'); + element.innerHTML = ReactDOMServer.renderToString( +
, + ); + + await act(() => { + ReactDOMClient.hydrateRoot(element, ); + }); + + assertConsoleErrorDev([]); + }); + + it('should not warn for class with CRLF (Windows line endings)', async () => { + const element = document.createElement('div'); + element.innerHTML = ReactDOMServer.renderToString( + , + ); + + await act(() => { + ReactDOMClient.hydrateRoot( + element, + , + ); + }); + + assertConsoleErrorDev([]); + }); + it('should throw rendering portals on the server', () => { const div = document.createElement('div'); expect(() => {