We were moving our blog and help center onto a new table of contents component, the kind that highlights the section you are reading. In the component playground it worked. In unit tests it worked. On the real site, it highlighted the last heading of the article and never let go, whether you were at the top of the page or halfway down.
The bug was not in the table of contents. It was one line that nearly every React codebase has somewhere:
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
In React 19, that line replaces the element's entire HTML every time its parent re-renders, even when the HTML has not changed. Here is how we found it, why it happens, what it breaks, and the one-line fix.
The symptom
A tracked table of contents works like this: find the heading elements in the article, and on every scroll, pick the last one whose top has passed under the sticky header. Ours looked the headings up once, when the page mounted, and kept the references.
On the live site every heading reported a getBoundingClientRect().top of 0. Zero is above any header offset, so every heading counted as already read, and the last one won. The elements were still in our array, but they were no longer in the document: heading.isConnected was false. A detached element has no layout, so the browser reports an empty rectangle at 0,0.
Something had thrown the article's DOM away and built a new one, with identical markup, right after we grabbed our references.
The cause
React 18 and React 19 handle this prop differently.
React 18 diffed the props of a DOM element before committing them, and for dangerouslySetInnerHTML it compared the HTML strings. This is from react-dom 18.3:
var nextHtml = nextProp ? nextProp[HTML$1] : undefined;
var lastHtml = lastProp ? lastProp[HTML$1] : undefined;
if (nextHtml != null) {
if (lastHtml !== nextHtml) {
(updatePayload = updatePayload || []).push(propKey, nextHtml);
}
}
Same string, no update.
React 19 dropped that separate diffing step. Its updateProperties compares each prop by reference, and when a prop differs it sets it again. Simplified from react-dom 19.2:
for (propKey in nextProps)
(nextProp = nextProps[propKey]),
(lastProp = lastProps[propKey]),
nextProp === lastProp || /* ... */ setProp(domElement, tag, propKey, nextProp, nextProps, lastProp);
and setProp, simplified the same way, does exactly one thing for this prop:
case "dangerouslySetInnerHTML":
// ...
domElement.innerHTML = value.__html;
{ __html: post.contentHtml } written inline is a new object on every render. It is never === the previous one, so every render of the parent assigns innerHTML, and assigning innerHTML makes the browser discard every child node and parse the string into new ones, even when the string is byte-for-byte the same.
When the parent re-renders
More often than you would think. On our pages the article re-rendered:
right after hydration, when the router settled its state, which is also exactly when our table of contents took its references;
on every hash change, so clicking a link in the table of contents rebuilt the article it was pointing into;
and on anything else that re-renders an ancestor: a theme toggle, a context update, a parent's state change.
The playground never re-rendered the parent, which is why the component looked fine there. The bug only exists in a real app.
What it breaks
Anything that assumes the nodes inside the injected HTML live as long as the component:
References to elements. Scroll spies,
IntersectionObservertargets, anything that measures a node you found earlier. They end up pointing at detached nodes.Listeners attached from outside. Lightboxes, copy buttons wired up in an effect, click tracking added with
addEventListener. The new nodes have none of them.Work done on the DOM after render. A syntax highlighter or a math renderer that ran on mount has its output replaced by the raw HTML again.
What the reader was doing. A text selection disappears, and so does keyboard focus inside the body.
Embeds. An
<iframe>inside the HTML, such as a YouTube player, is a new element, so it loads again from the start.
What survives: anything that is itself part of the HTML string, such as inline onclick attributes, because it is parsed again along with everything else.
The fix
Keep the object's identity stable, so React sees the same prop and skips the write:
import { useMemo } from 'react';
function EntryContent({ html }: { html: string }) {
const innerHtml = useMemo(() => ({ __html: html }), [html]);
return <div dangerouslySetInnerHTML={innerHtml} />;
}
Now the body is rewritten only when the HTML actually changes, which is the behaviour React 18 gave you for free.
This works in React Server Components as well. useMemo is one of the handful of hooks React exports to Server Components, along with useCallback, useId and useDebugValue, so the same component renders on the server and in the browser without a 'use client' directive.
If the HTML never changes for the life of the component, a constant defined outside the component works too. What does not work is anything that builds the object during render: an inline object literal, a spread, or a helper function that returns a new object each time.
Make your own code robust to it anyway
Memoising fixes the component that injects the HTML. It does not fix code elsewhere that holds references into it, because that code cannot know whether the component above it memoises. So in the table of contents we also changed how headings are found: instead of looking them up once and keeping the elements, the hook looks each one up by id every time it measures.
const measure = () => {
let current: string | null = null;
for (const id of ids) {
const element = document.getElementById(id);
if (!element) continue;
current ??= id;
if (element.getBoundingClientRect().top <= offset + 1) current = id;
}
setActiveId(current);
};
A lookup by id is cheap, and it is always the element that is in the document now. (It is getElementById rather than querySelector, because heading ids can start with a digit, which a CSS selector would need to escape.)
The rule we took away: never hold on to a node inside HTML you did not render with React. Look it up when you need it.
Test for it
Both halves are easy to test once you know to.
In an end-to-end test, mark a node inside the body, cause a re-render, and check the mark is still there. If the body was rebuilt, the new node will not have it:
await page.locator('#deployment').evaluate((heading) => heading.setAttribute('data-e2e-kept', 'true'));
await page.locator('a[href="#deployment"]').click(); // a hash change re-renders the page
await expect(page.locator('#deployment')).toHaveAttribute('data-e2e-kept', 'true');
In a unit test, the trap is that test environments such as jsdom report a zero-sized rectangle for every element, attached or not, so the original bug cannot show up at all. Stub getBoundingClientRect to return real positions for connected elements and zero for detached ones, then replace the headings with new elements that have the same ids, which is what a re-render does, and assert that the active heading is still right. A second test renders EntryContent twice with the same HTML and checks that the heading element is still the same object.
We watched each of these tests fail against the old code before trusting it.
The takeaway
In React 19, dangerouslySetInnerHTML={{ __html }} is not free on re-render. Every render of the parent replaces the element's whole subtree. Memoise the object with useMemo, keyed on the HTML, and do not keep references to nodes inside injected HTML.
This is how we found it: while building EntryContent, the component that renders a Kookee entry's body in @kookee/react. It memoises the object for you and works in Server Components. See React components for your blog, changelog and help center for the rest of the components, and Fixing problems with the React components if a script inside your article body has stopped working.