React 19 réécrit votre dangerouslySetInnerHTML à chaque rendu

Engineering

Je migrais le blog et le centre d’aide de Kookee vers un nouveau composant de table des matières, du genre qui met en évidence la section en cours de lecture. Dans le playground des composants, il marchait. Dans les tests unitaires, il marchait. Sur le vrai site, il mettait en évidence le dernier titre de l’article et ne le lâchait plus, que l’on soit en haut de la page ou à mi-hauteur.

Le bug n’était pas dans la table des matières. Il tenait en une ligne que presque toutes les bases de code React contiennent quelque part :

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

Avec React 19, cette ligne remplace tout le HTML de l’élément à chaque nouveau rendu du parent, même quand le HTML n’a pas changé. Voici comment je l’ai trouvé, pourquoi cela arrive, ce que cela casse, et le correctif d’une ligne.

Le symptôme

Une table des matières qui suit la lecture fonctionne ainsi : trouver les éléments de titre de l’article, et à chaque défilement, choisir le dernier dont le haut est passé sous l’en-tête fixe. La mienne cherchait les titres une seule fois, au montage de la page, et gardait les références.

Sur le site en production, chaque titre renvoyait un getBoundingClientRect().top à 0. Zéro est au-dessus de tout décalage d’en-tête, donc chaque titre comptait comme déjà lu, et le dernier l’emportait. Les éléments étaient toujours dans mon tableau, mais plus dans le document : heading.isConnected valait false. Un élément détaché n’a pas de mise en page, donc le navigateur renvoie un rectangle vide en 0,0.

Quelque chose avait jeté le DOM de l’article et en avait construit un nouveau, au balisage identique, juste après que j’avais récupéré mes références.

La cause

React 18 et React 19 traitent cette prop différemment.

React 18 comparait les props d’un élément DOM avant de les appliquer, et pour dangerouslySetInnerHTML, il comparait les chaînes HTML. Voici l’extrait de 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);
  }
}

Même chaîne, pas de mise à jour.

React 19 a supprimé cette étape de comparaison séparée. Son updateProperties compare chaque prop par référence, et quand une prop diffère, il la redéfinit. Version simplifiée de react-dom 19.2 :

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

et setProp, simplifié de la même façon, ne fait qu’une chose pour cette prop :

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

Écrit en ligne, { __html: post.contentHtml } est un nouvel objet à chaque rendu. Il n’est jamais === au précédent, donc chaque rendu du parent affecte innerHTML, et affecter innerHTML oblige le navigateur à jeter tous les nœuds enfants et à analyser la chaîne pour en créer de nouveaux, même quand la chaîne est identique octet pour octet.

Quand le parent refait un rendu

Plus souvent qu’on ne le pense. Sur les pages de Kookee, l’article refaisait un rendu :

  • juste après l’hydratation, quand le routeur stabilisait son état, c’est-à-dire exactement au moment où ma table des matières prenait ses références ;

  • à chaque changement de hash, si bien qu’un clic sur un lien de la table des matières reconstruisait l’article vers lequel il pointait ;

  • et à tout ce qui refait le rendu d’un ancêtre : un changement de thème, une mise à jour de contexte, un changement d’état d’un parent.

Le playground ne refaisait jamais le rendu du parent, et c’est pour cela que le composant y semblait correct. Le bug n’existe que dans une vraie app.

Ce que cela casse

Tout ce qui suppose que les nœuds du HTML injecté vivent aussi longtemps que le composant :

  • Les références à des éléments. Les scroll spies, les cibles d’IntersectionObserver, tout ce qui mesure un nœud trouvé plus tôt. Elles finissent par pointer vers des nœuds détachés.

  • Les écouteurs ajoutés de l’extérieur. Les lightboxes, les boutons de copie branchés dans un effet, le suivi des clics ajouté avec addEventListener. Les nouveaux nœuds n’en ont aucun.

  • Le travail fait sur le DOM après le rendu. La sortie d’un surligneur syntaxique ou d’un moteur de rendu de formules mathématiques exécuté au montage est de nouveau remplacée par le HTML brut.

  • Ce que faisait le lecteur. Une sélection de texte disparaît, tout comme le focus clavier dans le corps de l’article.

  • Les contenus intégrés. Une <iframe> dans le HTML, par exemple un lecteur YouTube, est un nouvel élément, donc elle se recharge depuis le début.

Ce qui survit : tout ce qui fait partie de la chaîne HTML elle-même, comme les attributs onclick en ligne, car c’est analysé à nouveau avec tout le reste.

Le correctif

Gardez une identité stable pour l’objet, afin que React voie la même prop et saute l’écriture :

tsx
import { useMemo } from 'react';

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

Le corps n’est alors réécrit que lorsque le HTML change vraiment, ce que React 18 faisait sans rien vous demander.

Cela fonctionne aussi dans les React Server Components. useMemo fait partie des quelques hooks que React exporte vers les Server Components, avec useCallback, useId et useDebugValue : le même composant est donc rendu sur le serveur et dans le navigateur sans directive 'use client'.

Si le HTML ne change jamais pendant la vie du composant, une constante définie hors du composant convient aussi. Ce qui ne marche pas, c’est tout ce qui construit l’objet pendant le rendu : un littéral d’objet en ligne, un spread, ou une fonction utilitaire qui renvoie un nouvel objet à chaque appel.

Protégez quand même votre propre code

La mémoïsation corrige le composant qui injecte le HTML. Elle ne corrige pas le code ailleurs qui garde des références vers ce HTML, car ce code ne peut pas savoir si le composant au-dessus de lui mémoïse. Dans la table des matières, j’ai donc aussi changé la façon de trouver les titres : au lieu de les chercher une fois et de garder les éléments, le hook cherche chacun par son id à chaque mesure.

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

Une recherche par id coûte peu, et elle renvoie toujours l’élément présent dans le document à cet instant. (C’est getElementById plutôt que querySelector, car un id de titre peut commencer par un chiffre, qu’un sélecteur CSS devrait échapper.)

La règle que j’en retiens : ne gardez jamais un nœud situé dans du HTML que vous n’avez pas rendu avec React. Cherchez-le au moment où vous en avez besoin.

Le tester

Les deux moitiés se testent facilement, une fois qu’on sait qu’il faut le faire.

Dans un test de bout en bout, marquez un nœud dans le corps, provoquez un nouveau rendu, et vérifiez que la marque est toujours là. Si le corps a été reconstruit, le nouveau nœud ne l’aura pas :

ts
await page.locator('#deployment').evaluate((heading) => heading.setAttribute('data-e2e-kept', 'true'));
await page.locator('a[href="#deployment"]').click(); // un changement de hash refait le rendu de la page
await expect(page.locator('#deployment')).toHaveAttribute('data-e2e-kept', 'true');

Dans un test unitaire, le piège est que les environnements de test comme jsdom renvoient un rectangle de taille nulle pour chaque élément, attaché ou non, si bien que le bug d’origine ne peut pas apparaître du tout. Remplacez getBoundingClientRect par un stub qui renvoie de vraies positions pour les éléments connectés et zéro pour les éléments détachés, puis remplacez les titres par de nouveaux éléments avec les mêmes ids, comme le fait un nouveau rendu, et vérifiez que le titre actif est toujours le bon. Un second test rend EntryContent deux fois avec le même HTML et vérifie que l’élément de titre est toujours le même objet.

J’ai vu chacun de ces tests échouer sur l’ancien code avant de m’y fier.

À retenir

Avec React 19, dangerouslySetInnerHTML={{ __html }} n’est pas gratuit lors d’un nouveau rendu. Chaque rendu du parent remplace tout le sous-arbre de l’élément. Mémoïsez l’objet avec useMemo, avec le HTML comme dépendance, et ne gardez pas de références vers des nœuds du HTML injecté.


Je l’ai trouvé en construisant EntryContent, le composant qui affiche le corps d’une entrée Kookee dans @kookee/react. Il mémoïse l’objet pour vous et fonctionne dans les Server Components. Voir Composants React pour votre blog, votre journal des modifications et votre centre d’aide pour les autres composants, et Résoudre les problèmes des composants React si un script dans le corps de votre article a cessé de fonctionner.