react
react copied to clipboard
Scripts executing in `dangerouslySetInnerHTML` interfere with `hydrateRoot`, causing a client-side re-render
React version: 18.2.0
Context:
This is a soft follow-up to a previously reported issue with React many years ago (https://github.com/facebook/react/issues/10923).
At The New York Times, we use custom embedded interactives rendered server-side with dangerouslySetInnerHTML.
These interactives have their own HTML, links, and scripts, running independently of the React tree. This allows editors and journalists to inject one-off, self-contained visual and interactive elements into our report without having to alter or re-deploy core infra.
A simplified example might look something like this (where script tags will modify the DOM as soon as the page has loaded):
const htmlString = `
<div id="server-test">server</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const serverTestElement = document.getElementById("server");
serverTestElement.textContent = "client";
});
</script>
`;
return <div dangerouslySetInnerHTML={{ __html: htmlString }} />;
The current behavior
In the current behavior the html element will appear as:
<div id="server-test">server</div>--> (initial browser load from server)<div id="server-test">client</div>--> (script tags run, changing the text content from server to client)<div id="server-test">server</div>--> (hydration sees a mismatch and defers to client-side render)
The expected behavior
In the expected behavior (which existed pre React 18), the html element should appear as:
<div id="server-test">server</div>--> (initial browser mount)<div id="server-test">client</div>--> (script tags run, changing the text content from server to client)<div id="server-test">client</div>--> (hydration ignores dangerouslySetInnerHTML changes)
Reasoning and Impact
This seems to be the result of the script tag manipulating the DOM element before hydration has completed, resulting in a hydration mismatch, which React has chosen to be much stricter about. This causes React to discard the server-rendered content modified by script tags and fall back to client-side rendering.
Unfortunately, the side effect of deferring to the client side is that HTML strings that contain <script/> tags that are rendered via dangerouslySetInnerHTML will not execute unless manually appended to an element such as the <head>. This is possible to do as a workaround by extracting each script tag manually, however, this disrupts the intended behavior where these scripts should execute immediately after browser load and creates an unintended script execution delay (and potentially other issues such as a flash of unstyled content).
Maybe something like this is a workaround
return <>
<div suppressHydrationWarning id="server-test">server</div>
<script dangerouslySetInnerHTML={{ __html: `(() => {
const serverTestElement = document.getElementById("server-test");
serverTestElement.textContent = "client";
})()` }} />
</>
Will this solution work?
const htmlString = '<div id="server-test">server</div>';
useEffect(() => {
const serverTestElement = document.getElementById("server-test");
serverTestElement.textContent = "client";
}, []);
return <div dangerouslySetInnerHTML={{ __html: htmlString }} />;
@ronakrrb @mathisonian Neither of the solutions above will work for our use case unfortunately. In the first example above, the specific scenario we'd like to imagine is that the interactive html is a bit of a black box and we'd like to do as little of string manipulation/parsing as possible. From taking a brief look at it as well, it also does not properly give us the behavior we'd like to have, and it defers to client-side render again.
In the second scenario the scripts are deferred to useEffect, which we'd also like to avoid, as the scripts should ideally run as soon as the browser has mounted given that they were server-rendered to begin with and there is a delay between initial render, hydration, and mounting.
@ilyaGurevich I agree the delay will be inevitable using useEffect. What about the below solution?
import { hydrateRoot } from 'react-dom/client';
const htmlString = '<div id="server-test"><div>server</div></div>';
const ClientSide = () => {
return (
<div suppressHydrationWarning={true}>client</div>;
);
}
hydrateRoot(document.getElementById('server-test'), <ClientSide />);
return <div dangerouslySetInnerHTML={{ __html: htmlString }} />;
A bit of rearranging and placement of the code will be required in your JSX / TSX file.
@ronakrrb, unfortunately this proposal won't quite work as we don't quite have information on the actual contents of the resulting html we're inputting into dangerouslySetInnerHtml, this would require us to be aware of the final executed output on the client before we've actually rendered it and run the
Hm, this doesn't sound right. I don't believe we go back to client render when dangerouslySetInnerHTML mismatches alone, but we would if there was a text mismatch somewhere else in the app. If you're able to provide a repro, that would help because I tried to repro this only with dSIH and wasn't able to.
And I'm also curious - wont this break for any client-side update?
For example, this displays "client" until you click the button and cause the app to re-render:
const [count, setCount] = useState(0);
const htmlString = `
<div id="server-test">server</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const serverTestElement = document.getElementById("server");
serverTestElement.textContent = "client";
});
</script>
`;
const handleClick = () => {
setCount((c) => c + 1);
};
return (
<>
<button onClick={handleClick}>{count}</button>
<div
dangerouslySetInnerHTML={{
__html: htmlString,
}}
/>
</>
);
@rickhanlonii so the assumption here is that any hydration mismatch will defer the entire root to a client-side re-render is that correct?
I can dig a little more an reproduce an example on hydration which contains only dangerouslySetInnerHtlml changes just to be sure!
Yes, any content mismatch (text, attributes, or elements), or additional Suspense boundary, or additional Fragment, or any error during rendering . dangerouslySetInnerHTML will warn, which you can suppress, but it will not switch to client rendering.
Here's a list of all the test cases: https://github.com/facebook/react/blob/9cae4428a13c1377df9b4a752f8d87646df461ec/packages/react-dom/src/tests/ReactDOMHydrationDiff-test.js
@rickhanlonii I guess I'm also wondering why there's such a significant difference in behavior here between the previous hydrating behavior (which retained content rendered from dangerouslySetInnerHtml) even if there was a hydration mismatch in React 16. Interestingly we have far more instances of hydration mis-matches right now (which I feel are non-actionable) that I'm not even sure how to handle, since the stack trace is a bit hard to parse currently than we did before the upgrade. I think I saw a PR somewhere to improve this, but I'm not sure of the status on that right now.
Just to highlight our challenges here, we have over 40,000 individually published bits of embedded HTML with scripts of various origin (svelte, custom javascript, JSON manifests, etc) on thousands of articles, many of those interactives relied on scripts being available and ready to run after being rendered on the server, so it's a bit difficult now to extract and run them manually on subsequent client re-renders, it works, but it's certainly a somewhat hacky workaround now on our end.
@ilyaGurevich I appreciate the difficulty that this is causing you and the scale of the issue. Let me try to summarize what I understand. There are three things happening here:
- The content in your
dangerouslySetInnerHTMLinjects scripts to manipulate the DOM - In React 16+ Client renders will replace
dangerouslySetInnerHTMLwith the client value - In React 18+, React will force a client render if there's a hydration mismatch
I may be missing something, but I think it's the combination of these three that is causing the bug you're seeing.
The content in your dangerouslySetInnerHTML injects scripts to manipulate the DOM
The content of your dangerouslySetInnerHTML includes both html content and <script> tags that may manipulate that content. This does not work in a client-only app, because browsers do not execute <script> tags added deeply in the tree. This worked in your use case only because of SSR, since the browser will execute scripts that exist deeply in the initial HTML.
This is a clever solution to storing and rendering CMS style content that includes scripts, but it's pretty problematic to the way React works because React doesn't support DOM manipulation within the tree, so any client render of that component will clear the changes the script makes, and the browser won't re-execute the script to make the changes back.
Client renders will replace dangerouslySetInnerHTML with the client value
On the client, since React owns all of the HTML content within a root, you're not supposed to manipulate the DOM within that root because React has no way to reconcile those changes with the changes React needs to make. So when React client renders, it will blow away those changes in favor of the output of the client render.
Even in React 16, even though we wouldn't switch the dangerouslySetInnerHTML content during hydration, any time the app re-rendered that component, it would replace the inner HTML with the client-side value, which would revert the content. That means this bug probably could exist already, and I'm not sure why you're not seeing it. Maybe you're relying on memo to prevent the component from rendering, but memo cannot be depended on as semantic a guarantee to prevent re-renders.
Because of this, like the rest of the DOM inside a React root, manipulating the content inside dangerouslySetInnerHTML isn't supported.
In React 18+, React will force a client render if there's a hydration mismatch
When using the server rendering APIs, the content rendered on the server was always supposed to match the client rendered content, and we've always warned when they different. From React's perspective, since you're changing the HTML content before hydration, the client and server HTML do not match, which is a bug. The warnings you were receiving before were not meant to be ignored, but fixed, as they indicated a issue with the app.
When the server content and client content mismatch, the app is in an invalid state. Until React 18, we opted to leave the app in an invalid state during hydration and only warn about the mismatch, hoping that the developer would see the warning and fix the mismatch, and knowing that the first update would render the client state and fix the mismatch anyway.
The new feature in React 18 is to switch the app to to client rendering within each Suspense boundary when there is a mismatch. This feature gets the app into at least a valid client-side state as soon as possible, limiting the exposure of the mismatch to the end user as much as possible. In most cases, this is best behavior, because the user will see that content anyone once the app re-renders. This is also important for us to do for a bunch of other reasons with the new features in React 18 such as streaming suspense (read the post above for more info).
But ideally, this should never happen. When there is a difference between the server rendered output and the client rendered output, it's a bug, and developers should fix it. In your case, you're actually depending on the content being different and depending on the component not re-rendering, which isn't a supported use case. The HTML pre-hydration and client HTML must match, and dangerouslySetInnerHTML doesn't "detach" the node from the React tree, it's a part of it and will update like any other component.
Again, I may be missing something but that's what I understand so far. I'd like to help you figure out a way forward though, so if you could share a repro we can brainstorm what we can do to help.
@rickhanlonii, Thanks for the really well-described response. It's much appreciated. I will work on setting up a proper github repro soon to show what I'm talking about. I think you've basically nailed it though, if what you're saying is that we have to assume a hydration mis-match will occur following SSR, then we'll need to defer to manually running the scripts on the client by appending them to the browser.
Even in React 16, even though we wouldn't switch the dangerouslySetInnerHTML content during hydration, any time the app re-rendered that component, it would replace the inner HTML with the client-side value, which would revert the content. That means this bug probably could exist already, and I'm not sure why you're not seeing it.
To answer your question about this, NYT previously opened up an issue awhile back asking about the same thing. We ended up creating our own workaround by stashing the dom nodes that were created on the server, and then replacing them with the previously rendered version during re-renders. This is a simplified example as we store stashed domNodes on the window using a registry.
// removes all children from the `destNode`
// then moves the `scrNodes` to the `destNode`
export function replaceChildren(destNode, srcNodes) {
while (destNode.firstChild) {
destNode.removeChild(destNode.firstChild);
}
// move all `srcNodes` to the `destNode`
while (srcNodes.length > 0) {
destNode.appendChild(srcNodes[0]);
}
}
class NYTComponent extends Component {
componentDidMount() {
if (window.registry[id].stashedDomNodes) {
replaceChildren(this.el, window.registry[id].stashedDomNodes);
}
}
}
componentWillUnmount() {
window.registry[id].stashedDomNodes = this.el.childNodes;
}
setupRef = el => {
this.el = el;
};
render() {
const { html, id, ...rest } = this.props;
return (
<div
{...rest}
ref={this.setupRef}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
}
@rickhanlonii coming back to you with a sandbox repo to reproduce this issue. Interestingly enough in the current example I provided above, this issue cannot be reproduced (assuming there are no other hydration mis-matches), and even with your example (with the button click) the dangerouslySetInnerHtml content also remains the same assuming the scripts had run.
It looks like to ensure we have no issues here in the long term, we'll need to find every hydration mismatch in our application. Unfortunately, the depth/breadth of our coverage is quite large and the specific code paths here are spread across a rather large codebase. I also understand there's an open PR to improve the diffing on hydration errors to be able to track down what went wrong more concretely, as the current error trace is occasionally hard to understand. Would you be able to provide more insight into this part of the process (resolving hydration mismatches and the improved diffing PR)?
Repo link: https://github.com/ilyaGurevich/vite-typescript-ssr-react
Closing as it looks like there are several things at play here:
- Scripts running in dangerouslySetInnerHtml do not create hydration mismatches
- React 18 is much more strict about hydration mismatches and causes the whole client-root to re-render as opposed to previously leaving the app in an invalid state.
- NYT pages had varying degrees of hydration mismatches for years and likely was loading in pages in an invalid state for quite some time, but now it has become more noticeable based on the above.
- Manually re-executing scripts inside of dangerously set inner html content on client-side re-renders is the only alternative for now while we pin down and fix all of our hydration mismatch errors, after we have more confidence we can revert to the previous behavior (which was to let CMS-created html run on it's own without interference).