React 19 reescribe tu dangerouslySetInnerHTML en cada render

Engineering

Estaba pasando el blog y el centro de ayuda de Kookee a un nuevo componente de índice, de los que resaltan la sección que estás leyendo. En el playground de componentes funcionaba. En los tests unitarios funcionaba. En el sitio real, resaltaba el último encabezado del artículo y no lo soltaba nunca, estuvieras al principio de la página o a la mitad.

El error no estaba en el índice. Estaba en una línea que casi todos los proyectos de React tienen en algún sitio:

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

En React 19, esa línea reemplaza todo el HTML del elemento cada vez que su padre se vuelve a renderizar, aunque el HTML no haya cambiado. Cuento cómo lo encontré, por qué pasa, qué rompe y cómo se arregla con una línea.

El síntoma

Un índice con seguimiento funciona así: encuentra los elementos de encabezado del artículo y, con cada scroll, elige el último cuya parte superior ya pasó por debajo de la cabecera fija. El mío buscaba los encabezados una vez, al montarse la página, y guardaba las referencias.

En el sitio real, todos los encabezados devolvían un getBoundingClientRect().top de 0. Cero queda por encima de cualquier offset de la cabecera, así que todos los encabezados contaban como ya leídos, y ganaba el último. Los elementos seguían en mi array, pero ya no estaban en el documento: heading.isConnected era false. Un elemento desconectado no tiene layout, así que el navegador devuelve un rectángulo vacío en (0, 0).

Algo había tirado el DOM del artículo y había construido uno nuevo, con un marcado idéntico, justo después de que yo tomara mis referencias.

La causa

React 18 y React 19 tratan esta prop de forma distinta.

React 18 comparaba las props de un elemento del DOM antes de aplicarlas, y para dangerouslySetInnerHTML comparaba las cadenas de HTML. Esto es 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);
  }
}

Misma cadena, ninguna actualización.

React 19 eliminó ese paso de comparación aparte. Su updateProperties compara cada prop por referencia, y cuando una prop es distinta la vuelve a asignar. Simplificado 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);

y setProp, simplificado de la misma manera, hace exactamente una cosa con esta prop:

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

{ __html: post.contentHtml } escrito en línea es un objeto nuevo en cada render. Nunca es === al anterior, así que cada render del padre asigna innerHTML, y asignar innerHTML hace que el navegador descarte todos los nodos hijos y analice la cadena para crear otros nuevos, aunque la cadena sea idéntica byte a byte.

Cuándo se vuelve a renderizar el padre

Más a menudo de lo que pensarías. En mis páginas, el artículo se volvía a renderizar:

  • justo después de la hidratación, cuando el router asentaba su estado, que es también justo cuando mi índice tomaba sus referencias;

  • en cada cambio de hash, así que hacer clic en un enlace del índice reconstruía el artículo al que apuntaba;

  • y con cualquier otra cosa que vuelva a renderizar un ancestro: un cambio de tema, una actualización de contexto, un cambio de estado del padre.

El playground nunca volvía a renderizar el padre, y por eso el componente parecía funcionar ahí. El error solo existe en una app real.

Qué rompe

Todo lo que da por hecho que los nodos dentro del HTML inyectado viven tanto como el componente:

  • Referencias a elementos. Scroll spies, objetivos de IntersectionObserver, cualquier cosa que mida un nodo encontrado antes. Acaban apuntando a nodos desconectados.

  • Listeners añadidos desde fuera. Lightboxes, botones de copiar conectados en un efecto, seguimiento de clics añadido con addEventListener. Los nodos nuevos no tienen ninguno.

  • Trabajo hecho sobre el DOM después del render. Un resaltador de sintaxis o un renderizador de fórmulas que se ejecutó al montar ve su resultado reemplazado otra vez por el HTML en bruto.

  • Lo que estaba haciendo el lector. Una selección de texto desaparece, y también el foco del teclado dentro del cuerpo.

  • Contenido incrustado. Un <iframe> dentro del HTML, como un reproductor de YouTube, es un elemento nuevo, así que vuelve a cargar desde el principio.

Lo que sobrevive: todo lo que forma parte de la propia cadena HTML, como los atributos onclick en línea, porque se vuelve a analizar junto con todo lo demás.

El arreglo

Mantén estable la identidad del objeto, para que React vea la misma prop y se salte la escritura:

tsx
import { useMemo } from 'react';

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

Ahora el cuerpo se reescribe solo cuando el HTML cambia de verdad, que es el comportamiento que React 18 te daba gratis.

Esto también funciona en React Server Components. useMemo es uno de los pocos hooks que React exporta a Server Components, junto con useCallback, useId y useDebugValue, así que el mismo componente se renderiza en el servidor y en el navegador sin una directiva 'use client'.

Si el HTML no cambia nunca durante la vida del componente, también sirve una constante definida fuera del componente. Lo que no sirve es cualquier cosa que construya el objeto durante el render: un objeto literal en línea, un spread o una función auxiliar que devuelve un objeto nuevo cada vez.

Haz tu propio código resistente a esto de todos modos

Memoizar arregla el componente que inyecta el HTML. No arregla el código de otras partes que guarda referencias dentro de ese HTML, porque ese código no puede saber si el componente de arriba memoiza. Así que en el índice también cambié cómo se encuentran los encabezados: en lugar de buscarlos una vez y guardar los elementos, el hook busca cada uno por id cada vez que mide.

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

Buscar por id es barato, y siempre devuelve el elemento que está en el documento ahora. (Es getElementById y no querySelector porque los ids de los encabezados pueden empezar por un dígito, y un selector CSS tendría que escaparlo).

La regla que me llevé: nunca guardes un nodo de un HTML que no renderizaste con React. Búscalo cuando lo necesites.

Pruébalo con tests

Las dos mitades son fáciles de probar cuando sabes que hay que hacerlo.

En un test end-to-end, marca un nodo dentro del cuerpo, provoca un nuevo render y comprueba que la marca sigue ahí. Si el cuerpo se reconstruyó, el nodo nuevo no la tendrá:

ts
await page.locator('#deployment').evaluate((heading) => heading.setAttribute('data-e2e-kept', 'true'));
await page.locator('a[href="#deployment"]').click(); // un cambio de hash vuelve a renderizar la página
await expect(page.locator('#deployment')).toHaveAttribute('data-e2e-kept', 'true');

En un test unitario, la trampa es que los entornos de test como jsdom devuelven un rectángulo de tamaño cero para todos los elementos, conectados o no, así que el error original no puede aparecer. Haz un stub de getBoundingClientRect que devuelva posiciones reales para los elementos conectados y cero para los desconectados; luego reemplaza los encabezados por elementos nuevos con los mismos ids, que es lo que hace un nuevo render, y comprueba que el encabezado activo sigue siendo el correcto. Un segundo test renderiza EntryContent dos veces con el mismo HTML y comprueba que el elemento del encabezado sigue siendo el mismo objeto.

Vi fallar cada uno de estos tests con el código antiguo antes de fiarme de ellos.

En resumen

En React 19, dangerouslySetInnerHTML={{ __html }} no sale gratis al volver a renderizar. Cada render del padre reemplaza todo el subárbol del elemento. Memoiza el objeto con useMemo, con el HTML como dependencia, y no guardes referencias a nodos dentro de HTML inyectado.


Lo encontré así, mientras construía EntryContent, el componente que renderiza el cuerpo de una entrada de Kookee en @kookee/react. Memoiza el objeto por ti y funciona en Server Components. Consulta Componentes React para tu blog, registro de cambios y centro de ayuda para el resto de los componentes, y Solución de problemas con los componentes React si un script dentro del cuerpo de tu artículo ha dejado de funcionar.