React 19 перезаписывает dangerouslySetInnerHTML при каждом рендере

Engineering

Я переносил блог и центр помощи на новый компонент оглавления — из тех, что подсвечивают раздел, который вы читаете. В песочнице компонентов он работал. В юнит-тестах работал. На настоящем сайте он подсвечивал последний заголовок статьи и больше его не отпускал, где бы вы ни были: в начале страницы или на середине.

Ошибка была не в оглавлении. Она была в строке, которая есть почти в любом проекте на React:

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

В React 19 эта строка заменяет весь HTML элемента при каждом повторном рендере родителя, даже если HTML не изменился. Ниже — как я это нашёл, почему так происходит, что это ломает и как это исправить одной строкой.

Симптом

Оглавление с отслеживанием работает так: находит в статье элементы заголовков и при каждой прокрутке выбирает последний, чей верхний край ушёл под липкую шапку. Моё искало заголовки один раз, при монтировании страницы, и хранило ссылки на них.

На живом сайте у каждого заголовка getBoundingClientRect().top был равен 0. Ноль выше любого отступа под шапку, поэтому каждый заголовок считался уже прочитанным, и побеждал последний. Элементы по-прежнему лежали в моём массиве, но в документе их уже не было: heading.isConnected был false. У отсоединённого элемента нет ни размеров, ни положения, поэтому браузер возвращает пустой прямоугольник в точке 0,0.

Что-то выбросило DOM статьи и построило новый, с такой же разметкой, сразу после того, как я забрал ссылки.

Причина

React 18 и React 19 обрабатывают этот проп по-разному.

React 18 перед коммитом вычислял разницу пропсов DOM-элемента, а для dangerouslySetInnerHTML сравнивал строки HTML. Вот код из 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);
  }
}

Та же строка — никакого обновления.

В React 19 этого отдельного шага сравнения нет. Его updateProperties сравнивает каждый проп по ссылке и, если проп отличается, устанавливает его заново. Упрощённо, из react-dom 19.2:

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

а setProp, упрощённый так же, делает для этого пропа ровно одно:

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

{ __html: post.contentHtml }, записанный прямо в JSX, — новый объект при каждом рендере. Он никогда не === предыдущему, поэтому каждый рендер родителя присваивает innerHTML, а присваивание innerHTML заставляет браузер выбросить все дочерние узлы и разобрать строку в новые, даже если строка совпадает байт в байт.

Когда родитель рендерится заново

Чаще, чем кажется. На моих страницах статья рендерилась заново:

  • сразу после гидратации, когда роутер устанавливал своё состояние, — ровно в тот момент, когда оглавление забирало ссылки;

  • при каждой смене хеша, так что клик по ссылке в оглавлении пересобирал статью, в которую эта ссылка вела;

  • и при всём остальном, что заново рендерит предка: переключении темы, обновлении контекста, изменении состояния родителя.

В песочнице родитель никогда не рендерился заново, поэтому там компонент выглядел исправным. Ошибка бывает только в настоящем приложении.

Что это ломает

Всё, что рассчитывает, что узлы внутри вставленного HTML живут столько же, сколько компонент:

  • Ссылки на элементы. Scroll spy, цели IntersectionObserver, всё, что измеряет найденный ранее узел. В итоге они указывают на отсоединённые узлы.

  • Обработчики, навешенные снаружи. Лайтбоксы, кнопки копирования, подключённые в эффекте, отслеживание кликов через addEventListener. У новых узлов ничего этого нет.

  • Работа с DOM после рендера. Подсветка синтаксиса или рендер формул, запущенные при монтировании, теряют свой результат: на его месте снова сырой HTML.

  • То, что делал читатель. Пропадает выделение текста, а с ним и фокус клавиатуры внутри статьи.

  • Встроенный контент. <iframe> внутри HTML, например плеер YouTube, становится новым элементом и загружается заново с самого начала.

Что выживает: всё, что само входит в строку HTML, например инлайновые атрибуты onclick, потому что они разбираются заново вместе со всем остальным.

Исправление

Держите объект одним и тем же, чтобы React видел тот же проп и пропускал запись:

tsx
import { useMemo } from 'react';

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

Теперь содержимое перезаписывается, только когда HTML действительно меняется, — так React 18 вёл себя и без этого.

Это работает и в React Server Components. useMemo — один из немногих хуков, которые React экспортирует для серверных компонентов, вместе с useCallback, useId и useDebugValue, поэтому один и тот же компонент рендерится и на сервере, и в браузере без директивы 'use client'.

Если HTML не меняется за всю жизнь компонента, подойдёт и константа, объявленная вне компонента. Не подходит всё, что создаёт объект во время рендера: объектный литерал прямо в JSX, спред или вспомогательная функция, которая каждый раз возвращает новый объект.

И всё равно сделайте свой код устойчивым к этому

Мемоизация исправляет компонент, который вставляет HTML. Но она не исправляет код в других местах, который держит ссылки на узлы внутри, ведь этот код не может знать, мемоизирует ли объект компонент выше. Поэтому в оглавлении я заодно поменял поиск заголовков: вместо того чтобы найти их один раз и хранить элементы, хук ищет каждый по 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);
};

Поиск по id дешёвый и всегда возвращает тот элемент, который сейчас в документе. (Здесь getElementById, а не querySelector, потому что id заголовка может начинаться с цифры, а в CSS-селекторе её пришлось бы экранировать.)

Правило, которое я из этого вынес: не держите ссылки на узлы внутри HTML, который отрендерил не React. Ищите узел, когда он нужен.

Как это протестировать

Обе половины легко проверить, если знать, что проверять.

В end-to-end-тесте пометьте узел внутри статьи, вызовите повторный рендер и проверьте, что метка на месте. Если содержимое пересобрали, у нового узла метки не будет:

ts
await page.locator('#deployment').evaluate((heading) => heading.setAttribute('data-e2e-kept', 'true'));
await page.locator('a[href="#deployment"]').click(); // смена хеша заново рендерит страницу
await expect(page.locator('#deployment')).toHaveAttribute('data-e2e-kept', 'true');

В юнит-тесте ловушка в том, что тестовые окружения вроде jsdom возвращают прямоугольник нулевого размера для любого элемента, подключённого к документу или нет, так что исходная ошибка там не может проявиться вообще. Подмените getBoundingClientRect, чтобы он возвращал настоящие координаты для подключённых элементов и нули для отсоединённых, затем замените заголовки новыми элементами с теми же id — именно это и делает повторный рендер — и проверьте, что активный заголовок по-прежнему правильный. Второй тест рендерит EntryContent дважды с одним и тем же HTML и проверяет, что элемент заголовка остался тем же объектом.

Каждый из этих тестов я сначала увидел упавшим на старом коде и только потом стал ему доверять.

Вывод

В React 19 dangerouslySetInnerHTML={{ __html }} не бесплатен при повторном рендере. Каждый рендер родителя заменяет всё поддерево элемента. Мемоизируйте объект через useMemo с HTML в зависимостях и не храните ссылки на узлы внутри вставленного HTML.


Я нашёл это, когда делал EntryContent — компонент, который рендерит текст записи Kookee в @kookee/react. Он сам мемоизирует объект и работает в серверных компонентах. Остальные компоненты описаны в статье React-компоненты для блога, журнала изменений и центра помощи, а если скрипт внутри текста статьи перестал работать, загляните в статью Решение проблем с React-компонентами.