open atlas
↑ К треку
Next.js с нуля до senior NEXT · 07 · 01

Иерархия error.tsx: что на самом деле ловит каждая граница, digest и когда reset() врёт

error.tsx оборачивает page сегмента, но не layout: ошибки layout всплывают к родительской границе, а падение корня ловит только global-error.tsx. Продакшен сводит серверные ошибки к digest; reset() лечит лишь временные сбои. Границы расставляют по радиусу поражения.

NEXT Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

«Чёрная пятница», 09:14. Команда e-commerce сделала домашнюю работу: после прошлогоднего падения чекаута они выкатили app/checkout/error.tsx — дружелюбный фолбэк, который сохраняет корзину на экране, извиняется и предлагает повторить. QA проверил его, бросив ошибку в page.tsx; зелёный. Этим утром SDK расчёта доставки, который они инициализируют в checkout/layout.tsx, давится сломанным фиче-флагом и кидает исключение во время рендера. Тщательно спроектированный фолбэк так и не появляется. Ошибка проплывает мимо него, не находит выше никакого app/error.tsx, роняет корневой layout — и пользователи видят неоформленный дефолтный экран ошибки фреймворка, посреди покупки, в самый трафиковый день года. Браузер показывает лишь «a server-side exception has occurred» плюс хеш digest, потому что продакшен редактирует серверные ошибки. Проходит сорок минут, прежде чем кто-то грепает серверные логи по этому digest и находит стектрейс парсера флагов. Граница не была сломана. Она стояла ровно там, где никогда не смогла бы поймать эту ошибку.

Граница оборачивает page, а не собственную раму

Ловушка размещения существует из-за того, как Next.js собирает файловые конвенции сегмента в одно React-дерево. Для каждого сегмента error.tsx становится React-границей ошибок, вложенной внутрь layout этого же сегмента:

// Что Next.js концептуально собирает из файлов одного сегмента
<Layout>                                {/* layout.tsx — СНАРУЖИ границы */}
  <ErrorBoundary fallback={<Error />}>  {/* error.tsx рендерит фолбэк */}
    <Page />                            {/* page.tsx + все вложенные сегменты */}
  </ErrorBoundary>
</Layout>

Поэтому checkout/error.tsx ловит всё, что брошено при рендере checkout/page.tsx и любого сегмента ниже, — но ошибка, брошенная самим checkout/layout.tsx, возникает в дереве выше границы и всплывает вверх, пока не встретит error.tsx родительского сегмента. Правило для запоминания: чтобы прикрыть layout, граница должна жить уровнем выше. Если выше по дереву её никто не ловит, падение доходит до корневого layout, и остаётся только global-error.tsx. Этот файл особенный трижды: в активном состоянии он заменяет весь корневой layout, поэтому обязан сам рендерить теги html и body; он обязан быть клиентским компонентом; и в разработке вы его почти не увидите, потому что ошибки перехватывает dev-оверлей, — это фактически поведение «только в продакшене», а значит проверять его надо на продакшен-сборке, а не в next dev.

Ещё одно роутинговое различие, которое по ошибке записывают в обработку ошибок: notFound() бросает специальную control-flow-ошибку, которую рендерит ближайший not-found.tsx, — error.tsx её не видит, как не видит и throw внутри redirect(). Границы ошибок — для сбоев, not-found — это семантика маршрутизации. Catch-all, сливающий отсутствующие сущности в границу ошибок, превращает корректный 404 в фальшивый 500 — неверный статус-код, неверное кеширование, неверная страница в поисковом индексе.

Викторина

В app/dashboard/ лежат layout.tsx, page.tsx и error.tsx. Провайдер, инициализируемый в dashboard/layout.tsx, бросает ошибку при рендере. app/error.tsx тоже существует. Что отрендерится?

reset() повторяет рендер — он не чинит мир

error.tsx получает { error, reset }. Вызов reset() перерендеривает содержимое границы — и именно это различие решает, честна ли ваша кнопка «Повторить». При временном сбое — сетевой моргнул, зависимость посреди деплоя, апстрим отвалился по таймауту — повтор действительно восстанавливает. При детерминированной ошибке — баг парсинга, null-поле в базе, сломанный флаг из пролога — reset() мгновенно бросает снова, и автоповторяющая граница превращается в плотный цикл, долбящий сломанную зависимость. Есть и тонкость с серверными компонентами: упавшая разметка пришла из серверного рендера, поэтому повторный рендер только на клиенте проигрывает тот же сломанный payload. Рабочий паттерн объединяет reset() с router.refresh() внутри transition и ограничивает попытки:

'use client'; // error.tsx — всегда клиентский компонент
import { useRouter } from 'next/navigation';
import { startTransition, useState } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  const router = useRouter();
  const [attempts, setAttempts] = useState(0);

  const retry = () => {
    setAttempts((n) => n + 1);
    startTransition(() => {
      router.refresh(); // заново запросить payload серверных компонентов
      reset();          // затем перерендерить детей границы
    });
  };

  return (
    <div role="alert">
      <p>Чекаут временно недоступен. Код обращения: {error.digest}</p>
      {attempts < 2
        ? <button onClick={retry}>Повторить</button>
        : <a href="/support">Написать в поддержку</a>}
    </div>
  );
}

Этот error.digest — продакшен-контракт (digest — хеш-отпечаток исходной серверной ошибки, одинаковый на сервере и в браузере). Когда серверный компонент бросает ошибку в продакшене, Next.js срезает message и stack до того, как что-либо уйдёт по проводу, — поэтому брошенный Error("duplicate key on users.email: alice@…") не утечёт схемой, SQL или PII в браузер. Клиент получает общий текст плюс digest: хеш, который Next.js также прикрепляет к серверному логу исходной ошибки. На этом хеше держится весь процесс поддержки — выводите digest в фолбэке, логируйте его на сервере, и скриншот пользователя превращается в поиск одним grep. Ошибки клиентских компонентов не редактируются: они уже исполнились в браузере. Отсюда два режима отказа: команда, прячущая digest от пользователей, вообще не имеет пути корреляции, а команда, льющая логи в эфемерный stdout с суточным удержанием, держит digest, указывающий на удалённые улики.

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

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

Проектирование яруса: границы по радиусу поражения

Разобравшись, где границы сидят в дереве, стоит задать следующий вопрос: сколько их рисовать — ошибиться здесь тише, чем пропустить границу, но не менее разрушительно. Не каждый сегмент заслуживает свой error.tsx. Каждый — это небольшой кусок клиентского бандла (граница — клиентский компонент: примерно 1–2 КБ плюс импортируемый ею UI) и, что важнее, решение об изоляции. Рабочая эвристика: рисуйте границу там, где остаток страницы всё ещё стоит показывать. Дашборду с четырьмя независимыми группами виджетов нужна граница на группу — упавшая панель аналитики не должна убивать навигацию и три остальные панели. Воронке чекаута нужна одна граница в корне воронки, чей фолбэк сохраняет контекст корзины. Маркетинговые страницы могут ехать на одном app/error.tsx. И ярус всегда завершают два файла: app/error.tsx, ловящий всё ниже корневого layout, и брендированный global-error.tsx как последний рубеж над ним.

Режим отказа от пере-ярусирования тише: когда умирает что-то системное — хранилище сессий, пул базы — четырнадцать виджетных границ рендерят четырнадцать вежливых грустных смайликов, и страница выглядит «деградировавшей, но живой», хотя не работает ничего. Системные сбои должны доходить до высокой границы, рассказывающей одну честную историю. Последний механический факт, унаследованный от самого React: границы ловят ошибки, брошенные во время рендера (серверного или клиентского) и в lifecycle, — но не внутри обработчиков событий и оторванного асинхронного кода. onClick, у которого упал fetch, никогда не активирует error.tsx — ему нужны собственный try/catch и состояние UI.

Викторина

В продакшене серверный компонент бросает Error('FK violation on orders.user_id=42'). Что на самом деле получает браузер пользователя?

Вспомните перед уходом
  1. 01
    Ошибка брошена в layout.tsx сегмента — какая граница её отрендерит и почему не error.tsx из той же папки?
  2. 02
    Что доходит до браузера, когда серверный компонент падает в продакшене, и как сделать это пригодным для работы?
Итог

Next.js собирает каждый сегмент маршрута как layout, оборачивающий границу ошибок, оборачивающую page, — отсюда правило размещения, решающее исход инцидентов: error.tsx ловит свой page и всё ниже, но никогда — layout рядом с собой; ошибки layout всплывают к границе родительского сегмента, а падение корневого layout минует весь ярус до global-error.tsx — клиентского компонента, обязанного рендерить собственные html и body, потому что он заменяет корневой layout целиком, и видимого только в продакшен-сборке: в разработке его маскирует dev-оверлей. notFound() и redirect() бросают control-flow-сигналы, которых error.tsx не видит, — слив 404 в границу ошибок подделывает 500. reset() перерендеривает содержимое границы: это честно восстанавливает временные сбои и врёт о детерминированных; поскольку разметка серверного компонента приходит с сервера, настоящий повтор объединяет reset() с router.refresh() внутри startTransition и ограничивает попытки, чтобы не долбить сломанную зависимость циклом. В продакшене фреймворк срезает message и stack у серверных ошибок и отправляет только digest, который также ложится в серверные логи, — показывайте его пользователям, храните логи, и скриншот из тикета превращается в один grep; ошибки клиентских компонентов приходят без редактирования, потому что уже исполнились в браузере. Ярус проектируют по радиусу поражения: граница там, где остаток страницы всё ещё заслуживает рендера, — на независимую область дашборда, одна на воронку чекаута с сохранением корзины, — плюс app/error.tsx и брендированный global-error.tsx как пол; помня, что ошибки обработчиков событий и оторванной асинхронщины не доходят ни до какой границы, а пере-ярусирование заставляет системный сбой косплеить четырнадцать локальных. Теперь, встретив фолбэк, который молчит несмотря на реальный сбой, сразу проверьте: что именно упало — если layout, нужная граница стоит уровнем выше.

Практика

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

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

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

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

Примени это

Примени этот урок в реальном проекте.

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

Trademarks belong to their respective owners. Editorial reference only.