Я переносил блог и центр помощи на новый компонент оглавления — из тех, что подсвечивают раздел, который вы читаете. В песочнице компонентов он работал. В юнит-тестах работал. На настоящем сайте он подсвечивал последний заголовок статьи и больше его не отпускал, где бы вы ни были: в начале страницы или на середине.
Ошибка была не в оглавлении. Она была в строке, которая есть почти в любом проекте на React:
<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:
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:
for (propKey in nextProps)
(nextProp = nextProps[propKey]),
(lastProp = lastProps[propKey]),
nextProp === lastProp || /* ... */ setProp(domElement, tag, propKey, nextProp, nextProps, lastProp);
а setProp, упрощённый так же, делает для этого пропа ровно одно:
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 видел тот же проп и пропускал запись:
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 при каждом измерении.
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-тесте пометьте узел внутри статьи, вызовите повторный рендер и проверьте, что метка на месте. Если содержимое пересобрали, у нового узла метки не будет:
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-компонентами.