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:
<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:
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:
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:
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:
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.
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:
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.