React 19 schreibt dein dangerouslySetInnerHTML bei jedem Render neu

Engineering

Ich stellte gerade den Blog und das Hilfecenter von Kookee auf eine neue Komponente für das Inhaltsverzeichnis um: eine, die den Abschnitt hervorhebt, den du gerade liest. Im Komponenten-Playground funktionierte sie. In den Unit-Tests funktionierte sie. Auf der echten Website hob sie die letzte Überschrift des Artikels hervor und blieb dort hängen, egal ob du oben auf der Seite warst oder in der Mitte.

Der Bug lag nicht im Inhaltsverzeichnis. Er steckte in einer Zeile, die fast jede React-Codebasis irgendwo hat:

tsx
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />

In React 19 ersetzt diese Zeile das gesamte HTML des Elements jedes Mal, wenn das Elternelement neu rendert, auch wenn sich das HTML nicht geändert hat. Hier steht, wie ich das gefunden habe, warum es passiert, was es kaputtmacht und wie der Fix in einer Zeile aussieht.

Das Symptom

Ein mitlaufendes Inhaltsverzeichnis funktioniert so: Es sucht die Überschriften-Elemente im Artikel und wählt bei jedem Scrollen die letzte, deren Oberkante unter den Sticky Header gewandert ist. Meins suchte die Überschriften einmal beim Mounten der Seite und behielt die Referenzen.

Auf der Live-Website meldete jede Überschrift ein getBoundingClientRect().top von 0. Null liegt über jedem Header-Offset, also galt jede Überschrift als schon gelesen, und die letzte gewann. Die Elemente waren noch in meinem Array, aber nicht mehr im Dokument: heading.isConnected war false. Ein abgehängtes Element hat kein Layout, also meldet der Browser ein leeres Rechteck bei 0,0.

Irgendetwas hatte das DOM des Artikels weggeworfen und ein neues mit identischem Markup gebaut, direkt nachdem ich die Referenzen geholt hatte.

Die Ursache

React 18 und React 19 behandeln diese Prop unterschiedlich.

React 18 hat die Props eines DOM-Elements vor dem Commit per Diff abgeglichen und bei dangerouslySetInnerHTML die HTML-Strings verglichen. Das hier stammt aus react-dom 18.3:

js
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);
  }
}

Gleicher String, kein Update.

React 19 hat diesen separaten Diff-Schritt gestrichen. Sein updateProperties vergleicht jede Prop per Referenz und setzt sie neu, wenn sie sich unterscheidet. Vereinfacht aus react-dom 19.2:

js
for (propKey in nextProps)
  (nextProp = nextProps[propKey]),
    (lastProp = lastProps[propKey]),
    nextProp === lastProp || /* ... */ setProp(domElement, tag, propKey, nextProp, nextProps, lastProp);

Und setProp, genauso vereinfacht, macht für diese Prop genau eine Sache:

js
case "dangerouslySetInnerHTML":
  // ...
  domElement.innerHTML = value.__html;

Inline geschrieben ist { __html: post.contentHtml } bei jedem Render ein neues Objekt. Es ist nie === dem vorherigen, also weist jeder Render des Elternelements innerHTML zu. Und eine Zuweisung an innerHTML lässt den Browser alle Kindknoten verwerfen und den String zu neuen Knoten parsen, auch wenn der String Byte für Byte gleich ist.

Wann das Elternelement neu rendert

Öfter, als man denkt. Auf meinen Seiten renderte der Artikel neu:

  • direkt nach der Hydrierung, als der Router seinen State festlegte, also genau dann, als mein Inhaltsverzeichnis seine Referenzen holte;

  • bei jeder Hash-Änderung, sodass ein Klick auf einen Link im Inhaltsverzeichnis genau den Artikel neu aufbaute, auf den er zeigte;

  • und bei allem anderen, was einen Vorfahren neu rendert: ein Theme-Umschalter, ein Context-Update, eine State-Änderung im Elternelement.

Im Playground renderte das Elternelement nie neu, deshalb sah die Komponente dort gut aus. Den Bug gibt es nur in einer echten App.

Was dabei kaputtgeht

Alles, was davon ausgeht, dass die Knoten im eingefügten HTML so lange leben wie die Komponente:

  • Referenzen auf Elemente. Scroll-Spys, Ziele eines IntersectionObserver, alles, was einen früher gefundenen Knoten misst. Sie zeigen am Ende auf abgehängte Knoten.

  • Von außen angehängte Listener. Lightboxen, Kopier-Buttons, die in einem Effect verdrahtet werden, Klick-Tracking über addEventListener. Die neuen Knoten haben nichts davon.

  • Arbeit am DOM nach dem Rendern. Hat ein Syntax-Highlighter oder ein Formel-Renderer beim Mounten gearbeitet, wird seine Ausgabe wieder durch das rohe HTML ersetzt.

  • Was der Leser gerade getan hat. Eine Textauswahl verschwindet, ebenso der Tastaturfokus im Inhalt.

  • Einbettungen. Ein <iframe> im HTML, etwa ein YouTube-Player, ist ein neues Element und lädt deshalb wieder von vorn.

Was überlebt: alles, was selbst Teil des HTML-Strings ist, etwa Inline-Attribute wie onclick, weil es mit allem anderen neu geparst wird.

Der Fix

Halte die Identität des Objekts stabil, damit React dieselbe Prop sieht und das Schreiben überspringt:

tsx
import { useMemo } from 'react';

function EntryContent({ html }: { html: string }) {
  const innerHtml = useMemo(() => ({ __html: html }), [html]);
  return <div dangerouslySetInnerHTML={innerHtml} />;
}

Jetzt wird der Inhalt nur neu geschrieben, wenn sich das HTML wirklich ändert. Genau dieses Verhalten hat React 18 von sich aus mitgebracht.

Das funktioniert auch in React Server Components. useMemo gehört neben useCallback, useId und useDebugValue zu den wenigen Hooks, die React für Server Components exportiert, also rendert dieselbe Komponente auf dem Server und im Browser ohne 'use client'-Direktive.

Wenn sich das HTML während der Lebensdauer der Komponente nie ändert, reicht auch eine Konstante außerhalb der Komponente. Was nicht funktioniert, ist alles, was das Objekt während des Renderns baut: ein Inline-Objektliteral, ein Spread oder eine Hilfsfunktion, die jedes Mal ein neues Objekt zurückgibt.

Mach deinen eigenen Code trotzdem robust dagegen

Memoisieren repariert die Komponente, die das HTML einfügt. Es repariert keinen Code an anderer Stelle, der Referenzen auf Knoten darin hält, denn dieser Code kann nicht wissen, ob die Komponente über ihm memoisiert. Deshalb habe ich im Inhaltsverzeichnis auch geändert, wie Überschriften gefunden werden: Statt sie einmal zu suchen und die Elemente zu behalten, sucht der Hook jede bei jeder Messung neu per ID.

ts
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);
};

Eine Suche per ID ist billig und liefert immer das Element, das gerade im Dokument ist. (Es ist getElementById und nicht querySelector, weil IDs von Überschriften mit einer Ziffer beginnen können, die ein CSS-Selektor escapen müsste.)

Die Regel, die ich daraus mitnehme: Halte nie einen Knoten in HTML fest, das du nicht mit React gerendert hast. Such ihn, wenn du ihn brauchst.

Teste darauf

Beide Hälften lassen sich leicht testen, wenn man einmal weiß, worauf man achten muss.

Markiere in einem End-to-End-Test einen Knoten im Inhalt, löse einen Re-Render aus und prüf, ob die Markierung noch da ist. Wurde der Inhalt neu aufgebaut, hat der neue Knoten sie nicht:

ts
await page.locator('#deployment').evaluate((heading) => heading.setAttribute('data-e2e-kept', 'true'));
await page.locator('a[href="#deployment"]').click(); // eine Hash-Änderung rendert die Seite neu
await expect(page.locator('#deployment')).toHaveAttribute('data-e2e-kept', 'true');

Im Unit-Test liegt die Falle darin, dass Testumgebungen wie jsdom für jedes Element ein Rechteck der Größe null melden, ob angehängt oder nicht. Der ursprüngliche Bug kann dort also gar nicht auftreten. Stubbe getBoundingClientRect so, dass es für angehängte Elemente echte Positionen und für abgehängte null liefert. Ersetze dann die Überschriften durch neue Elemente mit denselben IDs, genau das tut ein Re-Render, und prüf, ob die aktive Überschrift noch stimmt. Ein zweiter Test rendert EntryContent zweimal mit demselben HTML und prüft, ob das Überschriften-Element noch dasselbe Objekt ist.

Jeden dieser Tests habe ich gegen den alten Code fehlschlagen sehen, bevor ich ihm vertraut habe.

Das Fazit

In React 19 ist dangerouslySetInnerHTML={{ __html }} beim Re-Render nicht kostenlos. Jeder Render des Elternelements ersetzt den ganzen Teilbaum des Elements. Memoisiere das Objekt mit useMemo, abhängig vom HTML, und halte keine Referenzen auf Knoten im eingefügten HTML.


Gefunden habe ich das beim Bau von EntryContent, der Komponente in @kookee/react, die den Inhalt eines Kookee-Eintrags rendert. Sie memoisiert das Objekt für dich und funktioniert in Server Components. Die übrigen Komponenten findest du unter React-Komponenten für Blog, Changelog und Hilfecenter, und Probleme mit den React-Komponenten beheben hilft, wenn ein Skript im Text deines Artikels nicht mehr funktioniert.