O React 19 reescreve seu dangerouslySetInnerHTML a cada renderização

Engineering

Eu estava migrando o blog e a central de ajuda do Kookee para um novo componente de índice, daqueles que destacam a seção que você está lendo. No playground de componentes, funcionava. Nos testes unitários, funcionava. No site de verdade, ele destacava o último título do artigo e não largava mais, estivesse você no topo da página ou no meio dela.

O bug não estava no índice. Estava numa linha que quase toda base de código React tem em algum lugar:

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

No React 19, essa linha substitui todo o HTML do elemento sempre que o pai renderiza de novo, mesmo quando o HTML não mudou. A seguir: como encontrei o problema, por que ele acontece, o que quebra e a correção de uma linha.

O sintoma

Um índice que acompanha a leitura funciona assim: encontra os elementos de título no artigo e, a cada rolagem, escolhe o último cujo topo já passou por baixo do cabeçalho fixo. O meu buscava os títulos uma vez, quando a página montava, e guardava as referências.

No site em produção, todo título retornava getBoundingClientRect().top igual a 0. Zero fica acima de qualquer offset de cabeçalho, então todo título contava como já lido, e o último vencia. Os elementos continuavam no meu array, mas não estavam mais no documento: heading.isConnected era false. Um elemento desanexado não tem layout, então o navegador informa um retângulo vazio em 0,0.

Alguma coisa tinha jogado fora o DOM do artigo e construído um novo, com markup idêntico, logo depois que peguei as referências.

A causa

O React 18 e o React 19 tratam essa prop de formas diferentes.

O React 18 fazia o diff das props de um elemento DOM antes do commit e, para dangerouslySetInnerHTML, comparava as strings de HTML. Este trecho é do 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);
  }
}

Mesma string, nenhuma atualização.

O React 19 eliminou essa etapa separada de diff. O updateProperties dele compara cada prop por referência e, quando uma prop é diferente, aplica de novo. Simplificado do react-dom 19.2:

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

e o setProp, simplificado do mesmo jeito, faz exatamente uma coisa com essa prop:

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

{ __html: post.contentHtml } escrito inline é um objeto novo a cada renderização. Ele nunca é === ao anterior, então cada renderização do pai atribui innerHTML, e atribuir innerHTML faz o navegador descartar todos os nós filhos e transformar a string em nós novos, mesmo quando a string é idêntica byte a byte.

Quando o pai renderiza de novo

Com mais frequência do que você imagina. Nas páginas do Kookee, o artigo renderizava de novo:

  • logo depois da hidratação, quando o router estabilizava o estado, que é justamente quando o meu índice pegava as referências;

  • a cada mudança de hash, então clicar num link do índice reconstruía o artigo para o qual ele apontava;

  • e em qualquer outra coisa que renderize de novo um ancestral: uma troca de tema, uma atualização de contexto, uma mudança de estado no pai.

O playground nunca renderizava o pai de novo, e por isso o componente parecia funcionar lá. O bug só existe num app de verdade.

O que quebra

Tudo o que supõe que os nós dentro do HTML injetado vivem tanto quanto o componente:

  • Referências a elementos. Scroll spies, alvos de IntersectionObserver, qualquer coisa que meça um nó encontrado antes. Elas acabam apontando para nós desanexados.

  • Listeners adicionados de fora. Lightboxes, botões de copiar ligados num effect, rastreamento de cliques adicionado com addEventListener. Os nós novos não têm nenhum deles.

  • Trabalho feito no DOM depois da renderização. Um realçador de sintaxe ou um renderizador de fórmulas que rodou na montagem tem o resultado substituído outra vez pelo HTML cru.

  • O que o leitor estava fazendo. Uma seleção de texto some, e o foco do teclado dentro do corpo também.

  • Embeds. Um <iframe> dentro do HTML, como um player do YouTube, é um elemento novo, então carrega de novo desde o início.

O que sobrevive: tudo o que faz parte da própria string de HTML, como atributos onclick inline, porque é analisado de novo junto com o resto.

A correção

Mantenha estável a identidade do objeto, para o React ver a mesma prop e pular a escrita:

tsx
import { useMemo } from 'react';

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

Agora o corpo só é reescrito quando o HTML muda de fato, que é o comportamento que o React 18 dava de graça.

Isso funciona também em React Server Components. useMemo é um dos poucos hooks que o React exporta para Server Components, junto com useCallback, useId e useDebugValue, então o mesmo componente renderiza no servidor e no navegador sem a diretiva 'use client'.

Se o HTML nunca muda durante a vida do componente, uma constante definida fora do componente também serve. O que não serve é qualquer coisa que monte o objeto durante a renderização: um literal de objeto inline, um spread ou uma função auxiliar que retorna um objeto novo a cada vez.

Deixe seu próprio código resistente a isso de qualquer forma

Memoizar corrige o componente que injeta o HTML. Não corrige o código em outros lugares que guarda referências para dentro dele, porque esse código não tem como saber se o componente acima memoiza. Por isso, no índice, também mudei a forma de encontrar os títulos: em vez de buscá-los uma vez e guardar os elementos, o hook busca cada um pelo id toda vez que mede.

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

Uma busca por id é barata e sempre devolve o elemento que está no documento agora. (É getElementById, e não querySelector, porque ids de títulos podem começar com um dígito, que um seletor CSS precisaria escapar.)

A regra que tirei disso: nunca guarde um nó de dentro de um HTML que você não renderizou com o React. Busque o nó quando precisar dele.

Escreva testes para isso

As duas metades são fáceis de testar quando você sabe que precisa.

Num teste end-to-end, marque um nó dentro do corpo, provoque uma nova renderização e confira se a marca continua lá. Se o corpo foi reconstruído, o nó novo não vai ter a marca:

ts
await page.locator('#deployment').evaluate((heading) => heading.setAttribute('data-e2e-kept', 'true'));
await page.locator('a[href="#deployment"]').click(); // uma mudança de hash renderiza a página de novo
await expect(page.locator('#deployment')).toHaveAttribute('data-e2e-kept', 'true');

Num teste unitário, a armadilha é que ambientes de teste como o jsdom informam um retângulo de tamanho zero para todo elemento, anexado ou não, então o bug original nem tem como aparecer. Faça um stub de getBoundingClientRect que retorne posições reais para elementos conectados e zero para os desanexados, depois troque os títulos por elementos novos com os mesmos ids, que é o que uma nova renderização faz, e verifique se o título ativo continua certo. Um segundo teste renderiza EntryContent duas vezes com o mesmo HTML e confere se o elemento de título continua sendo o mesmo objeto.

Vi cada um desses testes falhar contra o código antigo antes de confiar nele.

O que levar daqui

No React 19, dangerouslySetInnerHTML={{ __html }} não sai de graça numa nova renderização. Cada renderização do pai substitui toda a subárvore do elemento. Memoize o objeto com useMemo, tendo o HTML como dependência, e não guarde referências a nós dentro de HTML injetado.


Foi assim que encontrei o problema: construindo o EntryContent, o componente que renderiza o corpo de uma entrada do Kookee no @kookee/react. Ele memoiza o objeto para você e funciona em Server Components. Veja Componentes React para seu blog, registro de alterações e central de ajuda para o resto dos componentes, e Como resolver problemas com os componentes React se um script dentro do corpo do artigo parou de funcionar.