open atlas
↑ К треку
React с нуля до senior RCT · 09 · 03

Ошибки вне рендера: глобальная сеть и пайплайн отчётов React 19

Хендлеры, async-код и таймеры не доходят до boundary: ловите их через window error и unhandledrejection и ведите отчёты в root-колбэки React 19. Дедуплицируйте дубли StrictMode, грузите source maps, тегируйте релизы — а hydration mismatch считайте recoverable, не fatal.

RCT Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Три недели подряд 0,3% сессий чекаута заканчивались кнопкой «Оплатить», которая не делала ничего. Дашборд ошибок всё это время был зелёным — каждый алерт в нём шёл через onError корневого boundary, а исключение жило в onSubmit-хендлере: парсер кошелькового токена давился форматом одного конкретного банка. Ошибки хендлеров не доходят до boundary, так что дашборд рапортовал о здоровом приложении, пока конверсия тихо истекала. Инженер нашёл баг, только воспроизведя его локально с тестовой картой того банка. Тогда команда подключила слушатели window error и unhandledrejection — и получила противоположную проблему: потоп. Каждая dev-ошибка приезжала дважды из-за StrictMode, дублирующего вызовы рендера; hydration mismatch от A/B-теста орал как фатальный краш, хотя React уже восстановился, перерисовав на клиенте; а продовые стеки были минифицированной кашей трёхрелизной давности, потому что никто не грузил source maps и не тегировал сборку. Урок симметричен: одни boundary недодают сигнала, сырая глобальная сеть передаёт его с избытком — и лишь пайплайн между ними делает любой из сигналов пригодным.

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

Пути побега: где ошибки реально покидают React

Boundary видит только исключения рендера, lifecycle-методов и конструкторов. Всё остальное выходит через три двери. Хендлеры событий выполняются как обычные вызовы функций на пути DOM-события, после коммита — непойманное исключение там улетает в window событием error, пока закоммиченный UI стоит нетронутым: та самая молча мёртвая кнопка «Оплатить». Эффекты с async-работой: тело эффекта синхронно, и его исключение до boundary доходит — но стоит положить внутрь async-функцию (а это нормальная форма загрузки данных), как отказ превращается в отклонённый промис, а отклонённый промис, которого никто не ждёт, стреляет unhandledrejection — и никогда boundary. Таймеры и подписки: к моменту, когда бросает колбэк setTimeout или onmessage WebSocket, стек рендера, который их завёл, — история; React даже не в стеке.

Глобальная сеть — два слушателя, и различие между ними несёт диагностический вес:

window.addEventListener("error", (e) => {
  report({ kind: "uncaught", error: e.error, stack: e.error?.stack });
});
window.addEventListener("unhandledrejection", (e) => {
  report({ kind: "rejection", error: e.reason }); // часто без componentStack, иногда вообще без стека
});

Событие error означает: синхронный код бросил, и никто не поймал — хендлеры, таймеры, сторонние скрипты. unhandledrejection означает: утекла async-цепочка — fetch без catch, async-эффект, fire-and-forget мутация. Ни один не несёт componentStack, потому что React в перехвате не участвовал: у вас есть JS-стек (какая функция бросила), но не цепочка компонентов (какая часть UI это содержала). Эта асимметрия — причина, почему маршрутизация ошибок внутрь boundary через showBoundary (урок 01) — больше, чем эстетика: она превращает анонимное глобальное событие в локализованный отчёт с componentStack и отрисованным фолбэком.

Викторина

Эффект загрузки данных написан как useEffect с async-функцией внутри, и fetch реджектится. Где всплывёт ошибка?

Root-опции React 19: три встроенные степени тяжести

React 19 сделал репортинг ошибок полноценным контрактом корня. createRoothydrateRoot) принимают три колбэка, и они делят каждую видимую React ошибку по признаку что случилось с пользователем:

import { createRoot } from "react-dom/client";

createRoot(container, {
  onUncaughtError(error, errorInfo) {
    // ни один boundary не поймал — дерево размонтировано; пользователь видел поломку
    report({ level: "fatal", error, componentStack: errorInfo.componentStack });
  },
  onCaughtError(error, errorInfo) {
    // boundary обработал — пользователь видел спроектированный фолбэк
    report({ level: "handled", error, componentStack: errorInfo.componentStack });
  },
  onRecoverableError(error, errorInfo) {
    // React вылечился сам — сюда падают hydration mismatch
    report({ level: "recoverable", error: error.cause ?? error, componentStack: errorInfo.componentStack });
  },
}).render(<App />);

Каждый получает ошибку плюс errorInfo.componentStack. Это разбиение — та модель тяжести, которую раньше приходилось реконструировать самим: onUncaughtError — класс белого экрана, материал для страничного алерта. onCaughtError — класс обработанного: boundary отрисовал фолбэк, у пользователя осталась рабочая страница; следите за частотой (всплеск значит, что виджет сломан в масштабе), но не будите дежурного из-за единичных событий. onRecoverableError — класс самоизлечения: React наткнулся на проблему, восстановился автоматически, и пользователь, скорее всего, ничего не заметил — для сбоев гидратации исходная причина лежит в error.cause. До React 19 пойманные ошибки вдобавок дублировались в консоль в dev-режиме, что задваивало всё, что было подключено к перехвату консоли; React 19 репортит каждую ошибку один раз, через эти хуки. Заметьте, чего root-опции по-прежнему не покрывают: исключения хендлеров и утёкшие реджекты в React не входят вовсе, так что два window-слушателя остаются в строю — колбэки корня заменяют гадание для видимых React ошибок, а не глобальную сеть.

Ошибки гидратации: recoverable-класс с реальной ценой

Hydration mismatch — серверный HTML разошёлся с первым клиентским рендером — канонический житель onRecoverableError. React выбрасывает повреждённый серверный HTML и перерисовывает затронутое дерево на клиенте, так что пользователь видит корректный UI, разве что с мерцанием. Считать эти ошибки фатальными (орущий дашборд из Hook) неверно дважды: пользователь не пострадал, а шум алертов хоронит настоящие краши. Но игнорировать их тоже неверно — каждый mismatch значит, что поддерево оплатило серверный рендер и затем отрисовалось ещё раз на клиенте, а обычные причины (таймстемпы и локальное форматирование, Math.random, browser-only ветки в рендере, A/B-тест, бакетящий по-разному на двух сторонах) — детерминированные баги, которые можно чинить. Правильная поза: отдельная непробуждающая метрика с бюджетом, расследуемая, когда частота сдвинулась.

Пайплайн: дедупликация, символикация, теги — или утонете

Когда вы видите трекер, заваленный дублями и минифицированной кашей, — это не проблема перехвата, это проблема пайплайна. Сырой перехват — лёгкая половина. Потоп из Hook случился из-за отсутствующих стадий пайплайна:

Дедупликация. Один и тот же сбой приходит через несколько дверей: onError boundary плюс onCaughtError, если подключены оба; ошибка, пойманная локально и переброшенная дальше; в dev StrictMode намеренно дублирует вызовы render-функций (и повторно гоняет эффекты), фабрикуя дубли любой ошибки рендер-пути — это фича для отлова нечистого кода и ложь в ваших метриках, если dev-события доезжают до продового трекера. Фингерпринтуйте события — сообщение плюс верхние кадры стека плюс релиз, — считайте вхождения и отбрасывайте dev-шум у источника.

Символикация. Продовые стеки минифицированы: t.map is not a function at a.render (main.3f2c1.js:1:48212) не локализует ничего, а собственные ошибки React в проде сжаты до нумерованных кодов со ссылкой на декодер. Без выгрузки source maps на каждую сборку каждый отчёт — археология. componentStack (из boundary или root-колбэков) переживает минификацию лучше — имена компонентов, — но и сами имена могут быть искорёжены, если их не сохранять или не маппить обратно.

Тег релиза. Стек осмыслен только относительно той сборки, что его породила. Тегируйте каждое событие релизом — и дашборд получает два самых полезных запроса: «началось ли это с деплоем?» и «исчезло ли после отката?» — разница между отладкой и гаданием.

Викторина

После включения StrictMode команда видит ошибки рендер-пути, посчитанные дважды в dev-трекере, и решает, что StrictMode сломан. Что происходит на самом деле?

Почему это работает

Почему React сам не пересылает ошибки хендлеров и промисов в boundary? Потому что к моменту их выстрела React нечего чинить и не к чему их привязать. Мандат boundary — целостность дерева: исключение рендера значит, что строящееся дерево непригодно, и React обязан раскрутиться до фолбэка. Исключение хендлера происходит при полностью консистентном закоммиченном дереве — размонтировать что-либо означало бы разрушить корректный UI по наитию. И атрибуция по-настоящему неоднозначна: setTimeout, заведённый компонентом, который уже размонтирован, принадлежит какому boundary? Промис-цепочка, прокинутая через три модуля? У платформы уже есть дома для этих ошибок — события error и unhandledrejection, — поэтому React оставляет владение вам, а showBoundary существует ровно затем, чтобы вы могли приписать ошибку к месту в дереве, когда вы — в отличие от React — знаете, куда она относится.

Вспомните перед уходом
  1. 01
    Сопоставьте каждый источник ошибок с его каналом перехвата и скажите, какие каналы несут componentStack.
  2. 02
    Назовите три стадии пайплайна между перехватом и дашбордом и режим отказа, который каждая предотвращает.
Итог

Boundary закрывают путь рендера; всё остальное покидает React через собственные двери. Хендлеры событий и колбэки таймеров бросают после коммита, при консистентном дереве на экране, поэтому React не трогает UI, и исключение доходит до события window error — молча мёртвая кнопка. Async-работа в эффектах превращается в промисы, которых никто не ждёт, и их реджекты стреляют unhandledrejection с ошибкой в e.reason. Ни один канал не знает componentStack, потому что React не участвовал в перехвате — это и есть глубокий аргумент за то, чтобы ловить в хендлерах и заводить в boundary через showBoundary, когда важен локализованный фолбэк. React 19 делает видимую React сторону контрактом корня: onUncaughtError — тяжесть белого экрана, по которой будят дежурного; onCaughtError — обработанная тяжесть, чью частоту отслеживают; onRecoverableError — класс самоизлечения: hydration mismatch падает туда с причиной в error.cause и заслуживает бюджетируемой метрики вместо алертов, потому что каждый из них — детерминированный баг расхождения SSR, стоящий клиентского ререндера, но не сломавший пользователя. Между перехватом и дашбордом сидит пайплайн, решающий, пригодно ли всё это: дедупликация по фингерпринту схлопывает один сбой, пришедший через несколько дверей, и поглощает намеренные dev-дубли StrictMode; выгруженные source maps возвращают минифицированным кадрам и нумерованным кодам React настоящий код; теги релиза заставляют дашборд отвечать, виноват ли деплой и вылечил ли откат. Недотянете перехват — дашборд зеленеет, пока чекаут истекает; пропустите пайплайн — он орёт, пока его не перестанут слушать. Мастерство — в том, чтобы были подключены все четыре источника и все три стадии между ними и пейджером. Теперь, когда вы увидите зелёный дашборд рядом с молча мёртвой кнопкой, вы первым делом проверите window error и unhandledrejection — и спросите, есть ли на месте все три стадии пайплайна.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.