diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 1b25e3727023..8a5058128199 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -2618,6 +2618,10 @@ function diffHydratedGenericElement( // Noop continue; case 'dangerouslySetInnerHTML': + // Skip innerHTML comparison only when suppressHydrationWarning is also set + if (props.suppressHydrationWarning === true) { + continue; + } const serverHTML = domElement.innerHTML; const nextHtml = value ? value.__html : undefined; if (nextHtml != null) { @@ -3220,10 +3224,12 @@ export function hydrateProperties( // even listeners these nodes might be wired up to. // TODO: Warn if there is more than a single textNode as a child. // TODO: Should we use domElement.firstChild.nodeValue to compare? + // Skip text content check if both dangerouslySetInnerHTML and suppressHydrationWarning are present if ( - typeof children === 'string' || - typeof children === 'number' || - typeof children === 'bigint' + !(props.dangerouslySetInnerHTML != null && props.suppressHydrationWarning === true) && + (typeof children === 'string' || + typeof children === 'number' || + typeof children === 'bigint') ) { if ( // $FlowFixMe[unsafe-addition] Flow doesn't want us to use `+` operator with string and bigint diff --git a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js index 5675c7eb0e73..9b3120160730 100644 --- a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js @@ -802,4 +802,46 @@ describe('ReactDOMServerHydration', () => { expect(ref.current).toBe(button); }); + + it('should skip hydration of elements with dangerouslySetInnerHTML and suppressHydrationWarning', async () => { + const htmlContent = { + __html: '

This is HTML content

', + }; + + function TestComponent() { + return ( +
+

Header

+
+

Footer

+
+ ); + } + + const container = document.createElement('div'); + container.innerHTML = ReactDOMServer.renderToString(); + + const testElement = container.querySelector('.testElement'); + expect(testElement).not.toBe(null); + expect(testElement.innerHTML).toBe( + '

This is HTML content

', + ); + + // change content before hydration to simulate a mismatch + testElement.innerHTML = '

Content changed

'; + + // Hydrate - should not produce warnings when both props are set + await act(() => { + ReactDOMClient.hydrateRoot(container, ); + }); + + // Verify the innerHTML is preserved (not cleared or modified) + expect(testElement.innerHTML).toBe( + '

Content changed

', + ); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index 0c758202b52f..76b3a7b94b89 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -276,7 +276,18 @@ function tryHydrateInstance( } hydrationParentFiber = fiber; - nextHydratableInstance = getFirstHydratableChild(instance); + // Skip hydrating children if this element has both suppressHydrationWarning + // and dangerouslySetInnerHTML - this allows developers to opt-out of + // hydration validation for elements where server/client HTML intentionally differs + const props = fiber.pendingProps; + if ( + props.dangerouslySetInnerHTML != null && + props.suppressHydrationWarning === true + ) { + nextHydratableInstance = null; + } else { + nextHydratableInstance = getFirstHydratableChild(instance); + } rootOrSingletonContext = false; return true; }