From 54400b9b79364252d625147dcc1bb9109ae3a09e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 21 Oct 2025 22:41:48 -0400 Subject: [PATCH] Normalize className as token sets for hydration comparison HTML defines `class` as a set of space-separated tokens. During hydration, server and client may differ only by whitespace, duplicates, or order. In DEV, treat both sides as equal after normalizing (split on ASCII whitespace, dedupe + sort) and early-return from warnForPropDifference when equal. - Add normalizeClassForHydration() - Compare canonical forms only for class/className (DEV only) - Tests: no warning for whitespace/order/dupes/CRLF between tokens - No production behavior change Specs: - ASCII whitespace: https://infra.spec.whatwg.org/#ascii-whitespace - class token list: https://html.spec.whatwg.org/multipage/dom.html#global-attributes Precedent: - 44c32fc (2017) CR/LF normalization in warnForPropDifference --- .../src/client/ReactDOMComponent.js | 21 ++++++++++++++ .../ReactServerRenderingHydration-test.js | 29 +++++++++++++++++++ 2 files changed, 50 insertions(+) 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(() => {