12k
All articles

Что означают ошибки «ResizeObserver loop completed»

Разберитесь в ошибках цикла ResizeObserver: почему браузер откладывает уведомления, как выявить реальные циклы и безопасно фильтровать оба сообщения.

OpenReplay Team
OpenReplay Team
Что означают ошибки «ResizeObserver loop completed»

«ResizeObserver loop completed with undelivered notifications» — это событие ошибки, которое генерирует браузер, а не исключение из вашего кода. Оно означает, что часть уведомлений об изменении размеров не успела обработаться в текущем кадре и была перенесена на следующий. В большинстве случаев его можно смело игнорировать.

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

Ключевые выводы

  • Браузер генерирует событие ошибки «ResizeObserver loop completed with undelivered notifications.» на window, когда уведомлениям об изменении размеров приходится ждать следующего кадра.
  • Старые версии браузеров сообщали о той же ситуации как «ResizeObserver loop limit exceeded», поэтому любой фильтр или обработчик в тестах должен учитывать оба сообщения.
  • Этот защитный механизм не даёт браузеру зависнуть, но не прерывает сам цикл: колбэк, который постоянно меняет размер наблюдаемого элемента, вызывает ошибку на каждом кадре.
  • try/catch эту ошибку не перехватывает, а window.onerror, SDK трекеров ошибок и обработчик uncaught:exception в Cypress — перехватывают.
  • Фильтруйте ошибку в трекере, а не глобальным подавлением через window.onerror, и продолжайте следить за частотой её появления.

Что означает «ResizeObserver loop completed with undelivered notifications»?

Сообщение «ResizeObserver loop completed with undelivered notifications» означает, что у браузера остались уведомления об изменении размеров, которые он не смог доставить в рамках одного кадра, поэтому он отложил их. Если хотите освежить в памяти сам API, прочитайте статью ResizeObserver vs window resize.

Браузер обрабатывает наблюдения за размерами после этапа layout и перед отрисовкой (paint). Если колбэк изменяет раскладку, браузер пересчитывает её и снова запускает колбэки. На каждом проходе он обрабатывает только элементы, расположенные в DOM глубже, чем самый неглубокий элемент, обработанный на предыдущем проходе. Всё, что не удовлетворяет этому правилу, ждёт следующего кадра, и браузер сообщает об ошибке. Эта последовательность описана в разделе Observation Errors на MDN, а правило глубины определено в спецификации Resize Observer.

Вы увидите одно из двух сообщений:

  • ResizeObserver loop completed with undelivered notifications. (актуальная формулировка, с точкой в конце)
  • ResizeObserver loop limit exceeded (формулировка в старых версиях браузеров)

Минимальный пример воспроизведения

Этот файл воспроизводит оба варианта поведения. Колбэк изменяет ширину того же элемента, за которым наблюдает.

<div id="box" style="width:100px;height:40px;background:tomato"></div>
<script>
  const box = document.getElementById('box');
  const LOOP = false; // set to true for the endless case

  window.addEventListener('error', (e) => console.log('window error:', e.message));

  const ro = new ResizeObserver(([entry]) => {
    const width = entry.contentBoxSize[0].inlineSize;
    entry.target.style.width = LOOP ? `${width + 10}px` : '200px';
  });
  ro.observe(box);
</script>

При LOOP = false первый вызов колбэка меняет ширину блока на 200px. Это изменение касается того же элемента, поэтому он не глубже только что обработанного. Браузер откладывает его и один раз выводит ошибку. На следующем кадре колбэк снова записывает 200px, ничего не меняется, и наблюдатель перестаёт срабатывать.

При LOOP = true блок расширяется на каждом кадре, и ошибка выводится тоже на каждом кадре.

Почему try/catch не перехватывает ошибку ResizeObserver?

try/catch никогда не перехватывает ошибку цикла ResizeObserver, потому что ваш колбэк ничего не выбрасывает — он завершается штатно. Браузер уже после этого генерирует событие error на window, вне какого-либо подконтрольного вам стека вызовов.

try {
  ro.observe(box);
} catch (err) {
  console.log('never reached'); // the error is dispatched on window later
}

window.onerror и слушатели error перехватывают события ошибок на уровне window, а SDK трекеров ошибок подписываются на те же события, поэтому трекеры её фиксируют. У события нет объекта Error со стеком вызовов, поэтому полезного стека в трекерах обычно нет.

Когда ошибка ResizeObserver — реальная проблема?

Ошибка цикла ResizeObserver важна, когда раскладка не стабилизируется. MDN отмечает, что защитный механизм предотвращает зависание браузера, но не сам цикл. Даже стабилизирующийся цикл может на мгновение показать промежуточную раскладку, пока изменения распределяются по кадрам.

Что вы видитеЧто это означаетЧто делать
Несколько событий после загрузки или изменения раскладки, страница выглядит стабильноЦикл стабилизировался за один-два кадраОтфильтровать
Всплески, связанные с одним компонентом, с заметным скачкомКомпонент меняет размер блока, за которым наблюдаетИсправить или сообщить разработчикам библиотеки
Непрерывный поток событий, растущий элемент или рост нагрузки на CPUЦикл, который никогда не стабилизируетсяИсправить немедленно

Как исправить цикл ResizeObserver?

Чтобы исправить цикл ResizeObserver, не давайте колбэку менять размер наблюдаемого элемента или перенесите эту запись за пределы текущего кадра. Перебирайте варианты в следующем порядке.

  1. Не меняйте размер наблюдаемого элемента из его же колбэка. Записывайте изменения в элемент, размер которого не влияет на наблюдение. Для чисто визуальной адаптивности CSS container queries позволяют вообще обойтись без JavaScript.
  2. Откладывайте запись через requestAnimationFrame. Если перенести запись в requestAnimationFrame, она выполнится в следующем кадре, и ошибка исчезнет. MDN рекомендует этот подход, когда непрерывное изменение размера вам действительно нужно. В случае с LOOP блок всё равно будет расти бесконечно — пропадёт только ошибка.
  3. Пропускайте размеры, которые вы установили сами. Для непреднамеренных циклов MDN предлагает сохранять ожидаемый размер каждого элемента в WeakMap:
const expected = new WeakMap();

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const width = entry.contentBoxSize[0].inlineSize;
    if (expected.get(entry.target) === width) continue; // our own change

    requestAnimationFrame(() => {
      const next = width + 10;
      entry.target.style.width = `${next}px`;
      expected.set(entry.target, next);
    });
  }
});

Поскольку состояние хранится отдельно для каждого элемента, наблюдать за несколькими элементами безопасно. Когда колбэк видит ширину, которую установил сам, он пропускает запись, и цикл завершается.

UI-библиотеки тоже вызывают эту ошибку

Типичный источник — колбэк ResizeObserver, который меняет размер наблюдаемого элемента. Часто такой колбэк находится в библиотеке компонентов, а не в вашем коде. Обычно это компоненты, которые измеряют свой контейнер, а затем меняют размер себя или дочернего элемента: например, автоматически растягивающиеся текстовые поля, виртуализированные таблицы, графики и поповеры. Если ошибка появляется при монтировании или открытии определённого компонента, обновите библиотеку или отправьте её разработчикам пример воспроизведения.

Как безопасно отфильтровать ошибку?

Учитывайте оба сообщения одним шаблоном и применяйте его в трекере и тест-раннере, а не в глобальном обработчике.

const RO_LOOP_ERROR =
  /ResizeObserver loop (completed with undelivered notifications|limit exceeded)/;

// cypress/support/e2e.js
Cypress.on('uncaught:exception', (err) => {
  if (RO_LOOP_ERROR.test(err.message)) return false;
});

Cypress генерирует uncaught:exception каждый раз, когда приложение выбрасывает необработанную ошибку, а обработчик, возвращающий false, сообщает Cypress, что тест проваливать не нужно. Это описано в Cypress Catalog of Events. В Sentry добавьте ignoreErrors: [RO_LOOP_ERROR]. Согласно справочнику по опциям Sentry, строковые элементы сопоставляются как подстроки, а регулярные выражения проверяются как есть. Свежие версии JavaScript SDK Sentry по умолчанию уже игнорируют современную формулировку, но не устаревшую, поэтому собственное правило нужно только для старой строки или для старых версий SDK.

Не подавляйте ошибку в window.onerror. Тотальное подавление скроет и случай бесконечного цикла, а вы потеряете сигнал о частоте. Фильтрация на уровне трекера, как описано в этих рекомендациях по логированию ошибок, сохраняет это решение наглядным и легко обратимым. Затем следите за количеством событий. Отдельный всплеск после изменения раскладки — это шум. Постоянный поток из одной сессии указывает на цикл, который никогда не стабилизируется. Запись сессии (session replay) покажет, видел ли пользователь скачок раскладки в момент возникновения ошибки, — так вы поймёте, шум это или нет. Тот же подход к разбору применим и к другим «шумным» ошибкам в продакшене, например «Failed to Fetch Dynamically Imported Module».

Заключение

В большинстве случаев ошибка «ResizeObserver loop completed» означает, что браузер отложил часть уведомлений, а раскладка стабилизировалась кадром позже. Проверьте, стабилизируется ли раскладка и нет ли заметных скачков. Если с обоими пунктами всё в порядке, отфильтруйте оба сообщения в трекере. Если нет — найдите колбэк, который меняет размер наблюдаемого элемента, и исправьте его с помощью отложенной записи или проверки ожидаемого размера.

Часто задаваемые вопросы

Лучше ли setTimeout или debounce, чем requestAnimationFrame?

Нет. Оба варианта переносят запись размера за пределы текущего кадра, поэтому ошибка исчезает, но ни один из них не останавливает цикл, который продолжает увеличивать элемент. Колбэк setTimeout может выполниться уже после того, как браузер выполнил отрисовку, поэтому пользователь может в течение кадра видеть старую раскладку, прежде чем изменение применится. requestAnimationFrame выполняется перед следующей отрисовкой, благодаря чему обновление синхронизировано с рендерингом.

Почему ошибка появляется сразу после монтирования компонента?

ResizeObserver отправляет первое уведомление при вызове observe() для элемента, который отрисован и имеет ненулевой размер. Если колбэк при этом первом уведомлении записывает в элемент новый размер, браузер откладывает следующее уведомление и сразу сообщает об ошибке. Компоненты, которые измеряют и меняют свой размер при монтировании, вызывают её именно так, даже если на странице ничего не меняет размер.

Могут ли CSS container queries заменить колбэк ResizeObserver?

Да, если колбэк лишь меняет стили в зависимости от ширины контейнера. Запросы к размеру контейнера применяют size containment, поэтому размер контейнера не зависит от его содержимого, и стили не могут влиять на измерение. Запросы к размеру поддерживаются в Chrome 105, Safari 16, Firefox 110 и более поздних версиях. Оставьте ResizeObserver для задач, с которыми CSS не справляется, например для пересчёта размеров графиков или количества строк в виртуализированных списках.

Как найти код, вызывающий ошибку, если стек вызовов пуст?

В режиме разработки временно замените window.ResizeObserver подклассом, который логирует каждый наблюдаемый элемент и оборачивает колбэк. Затем воспроизведите ошибку и посмотрите, размер какого элемента изменился непосредственно перед событием ошибки на window. Если колбэк находится внутри сторонней библиотеки, сузить круг поиска поможет поочерёдное удаление компонентов, пока ошибка не исчезнет.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.