vue-error-boundary-kit: error boundaries для Vue 3, каких не хватало в экосистеме

18.07.2026
vue-error-boundary-kit: error boundaries для Vue 3, каких не хватало в экосистеме

Один сломанный виджет на дашборде — и всё приложение превращается в белый экран. Одна карточка с товаром, у которой пришли кривые данные с бэкенда, роняет всю страницу каталога. Пользователь видит не «эта секция временно недоступна», а чистый белый экран и открытую консоль с красным текстом. Причём чаще всего это даже не баг в вашем коде — просто бэкенд вернул null там, где компонент ждал массив.

У React есть <ErrorBoundary> — паттерн, устоявшийся ещё в 2017 году, с официальной документацией и десятком популярных реализаций. У Vue 3 для этого есть только onErrorCaptured — низкоуровневый хук, который просто говорит «тут была ошибка», и всё. Ни UI, ни retry, ни стратегии репортинга, ни ответа на вопрос «а что делать, если внутри этого же хука снова что-то упадёт». В каждом проекте, где я его видел, вокруг хука заново собирается один и тот же набор: обёртка-компонент, свой fallback, свой способ не задублировать отправку одной и той же ошибки в Sentry, если boundary вложены друг в друга.

Написал vue-error-boundary-kit — закрывает это одним пакетом. Декларативный <ErrorBoundary> с fallback-слотом, retry и авто-восстановлением, useErrorBoundary() для программного использования, useGlobalErrorCapture() для того, что errorCaptured физически не может поймать, пять адаптеров репортинга (Sentry, Bugsnag, LogRocket, HTTP, console), обёртка с дедупом и rate-limit против шторма ошибок, лёгкая debug-панель истории. Ядро — 1.8 KB gzip, единственная peer-зависимость — Vue 3.4+. Ниже — разбор каждой фичи с примерами и объяснением, когда и зачем её включать.


Установка

bash Copy
npm install vue-error-boundary-kit

Поставляется как tree-shakeable ESM и CommonJS одновременно, с полными TypeScript-типами для всех пропсов, событий, опций и интерфейса адаптеров. Zero dependencies — ни у core, ни у адаптеров: Sentry, Bugsnag и LogRocket подключаются как тонкие обёртки над уже проинициализированным клиентом, сами SDK пакет не тянет. Каждый адаптер и каждая опциональная фича — отдельная точка входа в exports, которая не попадает в бандл, пока вы её явно не импортировали.

Copy
vue-error-boundary-kit             — <ErrorBoundary>, useErrorBoundary(), типы
vue-error-boundary-kit/global-capture   — useGlobalErrorCapture()
vue-error-boundary-kit/devtools         — createErrorHistory(), <ErrorHistoryPanel>
vue-error-boundary-kit/adapters/console
vue-error-boundary-kit/adapters/http
vue-error-boundary-kit/adapters/sentry
vue-error-boundary-kit/adapters/bugsnag
vue-error-boundary-kit/adapters/logrocket
vue-error-boundary-kit/adapters/rate-limit

<ErrorBoundary> — базовый компонент

Оборачиваете компонент, который может упасть. Если внутри default-слота что-то бросает исключение во время рендера или setup() (включая асинхронный) — вместо белого экрана показывается fallback-слот, а исходный компонент со всем своим состоянием размонтируется.

vue Copy
<script setup lang="ts">
import { ErrorBoundary } from 'vue-error-boundary-kit'
import UserProfile from './UserProfile.vue'

const userId = ref('42')

function handleError(error) {
  // error: CapturedError — полная информация о падении, см. раздел "Типы" ниже
}
</script>

<template>
  <ErrorBoundary @error="handleError">
    <template #default>
      <UserProfile :id="userId" />
    </template>
    <template #fallback="{ error, reset, retry, retryCount, canRetry }">
      <div class="error-card">
        <p>Не удалось загрузить профиль: {{ error.message }}</p>
        <button :disabled="!canRetry" @click="retry">Повторить ({{ retryCount }})</button>
      </div>
    </template>
  </ErrorBoundary>
</template>

Полный список пропсов

Проп Тип По умолчанию Что делает
resetKeys unknown[] При изменении любого значения — автоматический сброс
resetOnPropsChange boolean false Сброс при изменении любого пропа компонента-обёртки
beforeReset () => void Вызывается прямо перед сбросом (авто или ручным)
isolate boolean true Гасить ли ошибку локально или пробрасывать во внешний boundary
maxRetries number без лимита После скольки retry() кнопка должна задизейбливаться
reporter ErrorReporter | ErrorReporter[] Куда отправлять пойманные ошибки
shouldCatch (error: CapturedError) => boolean ловит всё Фильтр: какие ошибки вообще считать «нашими»
internalErrorPrefix string '[vue-error-boundary-kit]' Префикс служебного лога — см. ниже

События

  • error(error: CapturedError) — на каждую пойманную ошибку, в том числе всплывшую из дочернего boundary с isolate: false.
  • reset() — на каждый сброс, ручной или автоматический.

retry() vs reset() — в чём разница

Оба метода убирают состояние ошибки и заново пытаются отрендерить default-слот. Разница — в счётчике попыток:

  • reset() — полный сброс, retryCount обнуляется в 0. Логично использовать, когда причина ошибки точно устранена — например, пользователь поправил введённые данные.
  • retry() — «ещё одна попытка», retryCount увеличивается на единицу. Логично для нестабильных внешних API: сеть могла отвиснуть сама, без вмешательства пользователя.

Именно retry(), а не reset(), учитывается в maxRetries — после исчерпания лимита canRetry в fallback-слоте становится false, и кнопку можно задизейблить без ручной логики подсчёта:

vue Copy
<template #fallback="{ retryCount, canRetry, retry }">
  <button :disabled="!canRetry" @click="retry">
    {{ canRetry ? 'Повторить' : 'Лимит попыток исчерпан' }}
  </button>
  <p class="muted">Попыток: {{ retryCount }}</p>
</template>

Доступ к состоянию снаружи fallback-слота

Не всегда кнопка сброса физически может жить внутри fallback-слота — например, во вложенных boundary, где внешний уровень полностью перехватил рендер (см. раздел про isolate ниже). Всё, что доступно внутри слота, доступно и через ref на самом компоненте:

vue Copy
<script setup>
const boundary = ref()
</script>

<template>
  <ErrorBoundary ref="boundary"></ErrorBoundary>

  <button @click="boundary?.reset()">Сбросить откуда угодно</button>
  <p v-if="boundary?.hasError">Ошибок: retryCount = {{ boundary.retryCount }}</p>
</template>

Вызов reset()/retry() на boundary, который сейчас не в состоянии ошибки — безопасный no-op, можно не проверять hasError перед вызовом.


resetKeys, resetOnPropsChange, maxRetries — авто-восстановление

Самый частый в реальных проектах кейс: boundary упал на конкретных данных (например, некорректный ID в URL), а после навигации на другую страницу должен молча ожить сам, без ручного reset() откуда-то снаружи.

vue Copy
<ErrorBoundary :reset-keys="[route.params.id]">
  <template #default>
    <ProductCard :id="route.params.id" />
  </template>
  <template #fallback="{ error }">
    <ErrorState :message="error.message" />
  </template>
</ErrorBoundary>

Как только любое значение в массиве resetKeys меняется — сравнение идёт поэлементно через Object.is, точно так же, как в react-error-boundary, — boundary сбрасывается и пробует отрендерить default-слот заново. Массив можно передавать любой длины и комбинировать значения из разных источников:

vue Copy
<ErrorBoundary :reset-keys="[route.params.id, filters.category, retryToken]">

resetOnPropsChange — более грубый рубильник, без явного списка ключей: сброс при изменении любого пропа самого <ErrorBoundary> (включая, например, смену самого reporter). Удобно, когда компонент-обёртка целиком пересоздаётся снаружи через v-if/:key, и явно перечислять ключи неудобно.

maxRetries не сбрасывается сам по себе при срабатывании resetKeys — точнее, сбрасывается, потому что resetKeys вызывает именно reset(), а не retry() (см. таблицу выше). Это осознанное поведение: если пользователь перешёл на новую страницу, счётчик неудачных попыток предыдущей страницы не должен на неё влиять.


useErrorBoundary() — программное использование

Не всё удобно оборачивать в компонент. Ошибка распарсенного JSON, ошибка валидации формы, ошибка из обработчика клика на кастомном элементе — всё это errorCaptured физически не видит (подробный список — в разделе про useGlobalErrorCapture() ниже). Для таких случаев — composable с ровно той же моделью состояния, что и у <ErrorBoundary>, но без компонента-обёртки:

ts Copy
import { useErrorBoundary } from 'vue-error-boundary-kit'

const { error, hasError, reset, captureError } = useErrorBoundary({
  onError: (e) => analytics.track('import_failed', { message: e.message }),
  reporter: sentryReporter,
})

function importData(raw: string) {
  try {
    return JSON.parse(raw)
  } catch (err) {
    captureError(err, { source: 'manual', componentName: 'ImportPanel' })
  }
}

Опции

Опция Тип Описание
onError (error: CapturedError) => void Вызывается на каждый captureError(), независимо от reporter'а
beforeReset () => void Вызывается прямо перед reset()
reporter ErrorReporter | ErrorReporter[] Куда репортить
reportContext Record<string, unknown> Доп. данные, подмешиваемые в каждый report()
internalErrorPrefix string Префикс служебного лога, см. ниже

Возвращаемые значения

Свойство Тип Описание
error ShallowRef<CapturedError | null> Текущая ошибка или null
hasError ComputedRef<boolean> Удобно для v-if в шаблоне
reset() () => void Сбрасывает error в null, вызывает beforeReset
captureError(err, info?) (unknown, CaptureErrorInfo?) => CapturedError Регистрирует ошибку вручную, возвращает нормализованный объект

error — нарочно shallowRef, а не обычный ref. CapturedError.error может быть чем угодно — от Error до DOMException или вообще строки: если завернуть это в глубокую реактивность, Vue начнёт проксировать произвольный брошенный объект, что и бессмысленно (сам объект неизменяемый снапшот), и опасно (можно сломать instanceof-проверки или ссылочное сравнение у потребителя).

captureError принимает второй аргумент info — необязательные componentName, lifecycleHook, source. Если source не указан — по умолчанию 'manual'. Это удобно, когда нужно отличить в reporter'е «настоящую» ошибку рендера от той, что вы поймали руками:

ts Copy
window.addEventListener('unhandledrejection', (event) => {
  captureError(event.reason, { source: 'unhandledrejection', componentName: 'GlobalHandler' })
})

(Хотя для именно этого кейса есть готовый useGlobalErrorCapture(), который делает то же самое из коробки — см. ниже.)


Игнорируем ожидаемые ошибки — shouldCatch

Не каждое брошенное исключение — баг, который нужно чинить. Три частых примера из реальных проектов:

Отменённый запрос. Если компонент размонтировался раньше, чем fetch завершился, и вы отменяете его через AbortController — получаете AbortError. Это штатное поведение, не падение компонента.

vue Copy
<ErrorBoundary :should-catch="(e) => e.error?.name !== 'AbortError'">
  <UserProfile :id="userId" />
</ErrorBoundary>

Оффлайн-режим. Если приложение умеет работать без сети и явно проверяет navigator.onLine, ошибки сети в этом состоянии — ожидаемы, показывать для них boundary-fallback избыточно:

vue Copy
<ErrorBoundary :should-catch="(e) => navigator.onLine || !e.message.includes('Failed to fetch')">

Известный флейки-баг у стороннего виджета, который вы пока не можете починить, но и не хотите, чтобы он засорял репортинг:

vue Copy
<ErrorBoundary :should-catch="(e) => !e.stack?.includes('third-party-widget.js')">

Если предикат вернул false — ошибка проходит сквозь boundary полностью нетронутой: без смены состояния, без вызова reporter'а, без показа fallback, как будто этого <ErrorBoundary> вообще нет в дереве. Механика тут тоньше, чем кажется на первый взгляд: ошибка не проглатывается молча, а продолжает нативный Vue'шный onErrorCaptured-bubbling дальше вверх по дереву компонентов — до ближайшего boundary без фильтра, а если такого нет, до глобального app.config.errorHandler. Это принципиально отличается от isolate: false (см. ниже): там ошибка уже обработана локально и явно, целенаправленно передаётся наружу через собственный канал пакета. Здесь же — просто «это не моя ошибка, разбирайтесь дальше сами».

Тот же предикат работает и в useGlobalErrorCapture(), там он особенно полезен против шума от расширений браузера или сторонних скриптов на странице (классика — "ResizeObserver loop limit exceeded", безобидное предупреждение, которое многие браузеры зачем-то бросают как настоящую ошибку).

useErrorBoundary() намеренно не принимает shouldCatch — раз captureError() вызывается вами напрямую, вы уже сами решаете, вызывать его или нет, отдельный фильтр не нужен.


Вложенные boundary — isolate

По умолчанию (isolate: true) ошибка полностью гасится ближайшим <ErrorBoundary> — наружу не просачивается вообще. Это соответствует нативному поведению onErrorCaptured, когда хук возвращает false: остальное дерево компонентов, включая родительские boundary, ничего не узнаёт.

vue Copy
<!-- Родитель ничего не знает о падении CommentsWidget -->
<PageLayout>
  <ErrorBoundary>
    <CommentsWidget />
    <template #fallback="{ error }">
      <p>Комментарии временно недоступны</p>
    </template>
  </ErrorBoundary>
</PageLayout>

Но иногда внешнему уровню важно узнать о падении — например, чтобы вести общую статистику по дашборду или показать баннер «часть данных не загрузилась». Для этого — isolate: false:

vue Copy
<ErrorBoundary @error="reportToDashboardOwner">
  <div class="dashboard">
    <ErrorBoundary v-for="widget in widgets" :key="widget.id" :isolate="false">
      <component :is="widget.component" />
      <template #fallback="{ error }">
        <WidgetError :message="error.message" />
      </template>
    </ErrorBoundary>
  </div>
</ErrorBoundary>

Каждый виджет по-прежнему показывает свой локальный fallback — падение одного не рушит соседние. Но вдобавок внешний boundary получает error-событие и обновляет собственное состояние (hasError внешнего boundary тоже станет true). Технически это устроено через отдельный внутренний канал provide/inject, а не через нативный Vue-bubbling: если положиться на нативный bubbling (не возвращать false из хука), локальный fallback конкретного виджета вообще не успеет отрендериться — ошибка сразу уйдёт наверх целиком, и виджет пропадёт вместе со всем дашбордом. Именно поэтому в пакете это два принципиально разных механизма: shouldCatch — «это не моя ошибка вообще» через нативный bubbling, isolate: false — «я её обработал, но хочу ещё и уведомить наверх» через собственный канал.

Гарантия единственного репорта

Отдельно гарантируется: если и у внутреннего, и у внешнего boundary настроен reporter — сам репорт уйдёт ровно один раз, а не дважды. Механика простая: у каждого пойманного объекта CapturedError есть служебная скрытая метка «уже отправлен». Первый boundary в цепочке, у которого реально настроен reporter, отправляет ошибку и ставит метку. Когда та же самая ошибка (тот же объект, не копия) доходит до следующего boundary выше по дереву — reporter там видит метку и молча пропускает вызов. Работает вне зависимости от того, сколько уровней вложенности между ними и в каком порядке настроены reporter'ы.


Адаптеры репортинга

Единый интерфейс, вокруг которого построено всё:

ts Copy
interface ErrorReporter {
  report(error: CapturedError, context?: Record<string, unknown>): void | Promise<void>
}

Реализовать свой reporter — это просто объект с одним методом. Все пять готовых адаптеров — отдельные точки входа, каждая не тянется в бандл, если явно не импортирована:

ts Copy
import { createConsoleReporter } from 'vue-error-boundary-kit/adapters/console'
import { createHttpReporter } from 'vue-error-boundary-kit/adapters/http'
import { createSentryReporter } from 'vue-error-boundary-kit/adapters/sentry'
import { createBugsnagReporter } from 'vue-error-boundary-kit/adapters/bugsnag'
import { createLogRocketReporter } from 'vue-error-boundary-kit/adapters/logrocket'

adapters/console — дефолт для разработки

ts Copy
const reporter = createConsoleReporter({
  logger: console,              // можно подставить свой объект с методом error()
  prefix: '[my-app]',           // по умолчанию '[vue-error-boundary-kit]', '' — убрать совсем
})

Логирует через console.error, готов к использованию из коробки без конфигурации — есть и предсозданный синглтон consoleReporter с дефолтными настройками, если кастомизация не нужна:

ts Copy
import { consoleReporter } from 'vue-error-boundary-kit/adapters/console'

adapters/http — свой бэкенд, с батчингом

ts Copy
const reporter = createHttpReporter({
  endpoint: '/api/errors',
  batchInterval: 5000,          // копим ошибки 5 секунд и шлём одним запросом (0 — сразу)
  maxBatchSize: 20,             // форс-флаш раньше времени, если накопилось много
  headers: { Authorization: `Bearer ${token}` },
  serialize: (error, context) => ({ ...error, appVersion: APP_VERSION }), // свой формат payload
})

При закрытии вкладки обычный fetch может быть прерван браузером на середине — запрос просто не долетит. На этот случай висит слушатель pagehide, который форсированно флашит всё, что накопилось в очереди, через navigator.sendBeacon — этот API специально спроектирован так, чтобы гарантированно доставить данные даже при закрытии страницы. В обычной ситуации (не выгрузка страницы) используется fetch, потому что он гибче: поддерживает заголовки, кастомную сериализацию, стриминг.

adapters/sentry, adapters/bugsnag, adapters/logrocket — тонкие обёртки

Все три — не импортируют сами SDK как зависимость пакета. Вы сами инициализируете @sentry/vue / @bugsnag/js / logrocket где угодно в приложении, а адаптеру передаёте уже готовый инстанс:

ts Copy
import * as Sentry from '@sentry/vue'
import { createSentryReporter } from 'vue-error-boundary-kit/adapters/sentry'

Sentry.init({ app, dsn: '...' })
const reporter = createSentryReporter({ client: Sentry, tags: { team: 'frontend' } })
ts Copy
import Bugsnag from '@bugsnag/js'
import { createBugsnagReporter } from 'vue-error-boundary-kit/adapters/bugsnag'

const client = Bugsnag.start({ apiKey: '...' })
const reporter = createBugsnagReporter({ client, severity: 'error' })
ts Copy
import LogRocket from 'logrocket'
import { createLogRocketReporter } from 'vue-error-boundary-kit/adapters/logrocket'

LogRocket.init('app/id')
const reporter = createLogRocketReporter({ client: LogRocket, tags: { plan: 'pro' } })

Адаптеры не проверяют, что вы передали именно настоящий клиент нужного SDK — им достаточно, чтобы объект структурно совпадал с ожидаемым интерфейсом (captureException(error, hint?) у Sentry и LogRocket, notify(error, onError?) у Bugsnag). Это значит, что в тестах вместо реального клиента можно легко подставить простой мок с тем же методом — не нужно ни поднимать SDK, ни мокать модуль целиком.

Отдельная деталь про LogRocket: их API требует, чтобы значения в tags/extra были скалярными (строка, число, булево) — нельзя передать вложенный объект напрямую. Адаптер сам сериализует нескалярные значения контекста через JSON.stringify, чтобы это не превращалось в рантайм-ошибку внутри самого reporter'а.

Перед реализацией всех трёх сверял актуальные сигнатуры (Bugsnag.notify(error, onError), LogRocket.captureException(error, { tags, extra })) с официальной документацией — не по памяти, документация SDK меняется, и придуманная «на глаз» обёртка рисковала бы просто не работать у реальных пользователей.


Rate-limit и дедуп — защита от шторма ошибок

Список из тысячи элементов, у каждого — сломанный рендер из-за одной и той же причины. Без защиты это тысяча одинаковых по сути репортов в Sentry за секунду — шум, который заслоняет реальную картину и может упереться в лимиты платного тарифа. createRateLimitedReporter оборачивает один reporter или массив reporter'ов дедупом и потолком:

ts Copy
import { createRateLimitedReporter } from 'vue-error-boundary-kit/adapters/rate-limit'

const reporter = createRateLimitedReporter([sentryReporter, consoleReporter], {
  maxPerWindow: 10,       // не больше 10 репортов за окно, по умолчанию тоже 10
  windowMs: 10_000,       // размер окна, по умолчанию 10 секунд
  dedupWindowMs: 10_000,  // по умолчанию равен windowMs
  onSuppressed: (error, { reason, count }) => {
    // reason: 'dedup' | 'rate-limit', count — сколько раз эта сигнатура уже была подавлена
    metrics.increment(`errors.suppressed.${reason}`)
  },
})

Две независимые проверки происходят на каждый вызов report():

  1. Дедуп. Ключ сигнатуры — комбинация source + componentName + message. Если такая же ошибка уже была отправлена в пределах dedupWindowMs — этот вызов гасится, наружу уходит только самый первый.
  2. Rate-limit. Даже если сигнатуры разные (100 разных компонентов упали по 100 разным причинам одновременно) — общий поток репортов всё равно ограничен maxPerWindow за windowMs, вне зависимости от того, дедуп-совпадение это или нет.

Обе проверки идут именно в таком порядке: сперва дедуп, потом лимит окна — так частый повтор одной и той же ошибки не съедает бюджет maxPerWindow, оставляя место для действительно новых, разных сигнатур.

Отдельно предусмотрена защита от утечки памяти на случай, если за долгую сессию накопится очень много разных уникальных сигнатур ошибок (тот самый «шторм» из тысячи разных причин): внутренняя карта отслеживания периодически подчищает устаревшие записи, если её размер вырастает слишком большим — вручную ничего чистить не нужно.

Результат — тоже обычный ErrorReporter, подставляется куда угодно, где ожидается reporter: и в <ErrorBoundary>, и в useErrorBoundary(), и в useGlobalErrorCapture().


Devtools — история ошибок без браузерного расширения

Полноценная интеграция с расширением Vue Devtools потребовала бы @vue/devtools-api как зависимость — а это прямое нарушение принципа «zero dependencies», на котором построен весь пакет. Вместо этого — лёгкая in-memory история, реализованная максимально просто: она использует тот же самый механизм reporter'ов, никакой отдельной интеграции с <ErrorBoundary> под капотом не требуется.

ts Copy
import { createErrorHistory, ErrorHistoryPanel } from 'vue-error-boundary-kit/devtools'

const history = createErrorHistory({ limit: 50 }) // по умолчанию тоже 50
vue Copy
<ErrorBoundary :reporter="[sentryReporter, history.record]"></ErrorBoundary>

<ErrorHistoryPanel v-if="isDev" :history="history" />

history.record — такой же ErrorReporter, как консольный или HTTP: он просто складывает каждую пойманную ошибку в реактивный массив history.entries (самая свежая — первой, старые вытесняются по достижении limit). Раз это обычный reporter, его можно передать в массив вместе с «боевыми» reporter'ами, и он будет получать ту же самую ошибку — включая дедуп-гарантию единственного вызова из раздела про isolate выше, если несколько boundary настроены с одной и той же историей.

ts Copy
history.entries.value    // ErrorHistoryEntry[] — CapturedError + id для :key
history.clear()          // очистить историю

<ErrorHistoryPanel> — готовый компонент со списком, временем, источником и именем компонента для каждой записи, и кнопкой очистки. Сознательно на инлайн-стилях, а не <style scoped>: Vite вынес бы такой CSS в отдельный файл, который пришлось бы отдельно импортировать в проект (как это устроено, например, в vue-toast-kit) — для маленького debug-инструмента это лишнее трение, проще, когда компонент работает сразу после импорта, без второго шага.


useGlobalErrorCapture() — то, что errorCaptured физически не может увидеть

Тут стоит один раз подробно разобраться, что вообще ловит onErrorCaptured, а что нет — потому что интуиция часто подводит.

onErrorCaptured (а значит, и <ErrorBoundary>) действительно ловит:

  • синхронные ошибки в рендере и setup() компонента;
  • ошибки в async setup() после await<Suspense> и без);
  • ошибки в скомпилированных Vue-обработчиках v-on — это касается и @click="mayThrow" на нативных элементах, и обработчиков событий, которые эмитит дочерний компонент;
  • ошибки в watcher-колбэках и хуках директив.

А вот что он не видит в принципе, потому что это происходит вне механизма, через который Vue сам оборачивает вызовы:

  • ошибки в колбэке addEventListener, повешенном напрямую, в обход Vue-биндинга;
  • ошибки в setTimeout/произвольных Promise-цепочках, не связанных с async setup() компонента;
  • необработанные (unhandled) promise rejection.

Для этой категории — отдельная точка входа, не подключается по умолчанию (в SSR-бандле, который её не импортирует, она не занимает ни байта):

ts Copy
import { useGlobalErrorCapture } from 'vue-error-boundary-kit/global-capture'

const { stop } = useGlobalErrorCapture({
  reporter: sentryReporter,
  onError: (e) => console.warn('uncaught:', e),
  shouldCatch: (e) => !e.message.includes('ResizeObserver loop'),
  captureErrors: true,       // ловить window 'error' — по умолчанию true
  captureRejections: true,   // ловить unhandledrejection — по умолчанию true
})

Внутри — обычные window.addEventListener('error', …) и window.addEventListener('unhandledrejection', …), всё проходит через тот же паттерн reporter/onError/shouldCatch, что и остальной пакет. captureErrors/captureRejections позволяют включить только один из двух источников, если второй уже обрабатывается чем-то другим в проекте.

Функция возвращает { stop } — вызов снимает оба слушателя вручную. Но чаще всего этого делать не приходится: если useGlobalErrorCapture() вызван внутри активного Vue effect scope (например, прямо в setup() компонента), очистка происходит автоматически при размонтировании — по тому же механизму, что и авто-снятие watch.


internalErrorPrefix — и один найденный по дороге баг

У пакета есть «служебный» уровень логирования, отдельный от reporter. Он срабатывает, только если сломался не сам компонент, а ваш собственный колбэк — onError, beforeReset, onSuppressed или сам reporter бросил исключение внутри себя. Такие сбои перехватываются и логируются отдельно, чтобы одна ошибка в обработчике не превращалась в необработанное исключение, роняющее уже не приложение, а сам механизм обработки ошибок:

ts Copy
useErrorBoundary({
  internalErrorPrefix: '[my-app]',   // по умолчанию '[vue-error-boundary-kit]', '' — убрать
  onError: (e) => {
    throw new Error('а если и этот колбэк сломается?') // будет поймано и залогировано, не улетит дальше
  },
})

Опция доступна одинаково в <ErrorBoundary>, useErrorBoundary(), useGlobalErrorCapture() и createRateLimitedReporter().

Добавляя эту опцию, я писал тест на «а что если beforeReset бросает исключение прямо во время клика по retry» — и тест неожиданно не проходил при использовании имени onReset, которое я изначально выбрал для этого пропа <ErrorBoundary> по аналогии с react-error-boundary. Оказалось: этот же компонент эмитит событие reset, а Vue сам автоматически выводит для любого эмита emit('reset') соответствующий проп-слушатель с именем onReset — буквально тот же самый ключ. emit() у Vue вызывает props.onReset, если он присутствует, вообще не разбираясь, «настоящий» ли это объявленный проп компонента или просто случайно совпавшее по имени поле. В итоге пользовательский onReset реально вызывался дважды за один сброс: один раз — через мой собственный, аккуратно обёрнутый в try/catch вызов, и ещё раз — самим Vue через внутренности emit(), уже снаружи этого try/catch. Если такой колбэк бросал исключение — оно улетало необработанным rejection'ом мимо всей защиты от рекурсии, которую я сам же и проектировал.

Пакет ещё не был опубликован, так что переименовал проп в beforeReset — и заодно useErrorBoundary(), хотя там этой проблемы никогда не было (composable не проходит через emit() вообще). Урок: если компонент одновременно эмитит событие и объявляет проп-колбэк, их имена не могут совпадать с автоматически выведенным onXxx для этого события — иначе Vue сам создаст коллизию, о которой ни линтер, ни компилятор не предупредят.


Типы

Всё построено вокруг одного объекта, который передаётся везде — в fallback-слот, в onError, в reporter.report():

ts Copy
interface CapturedError {
  error: unknown                 // исходное брошенное значение как есть
  message: string
  stack?: string
  componentName?: string
  lifecycleHook?: string         // строка от Vue: 'render function', 'setup function' и т.д.
  source: 'render' | 'async' | 'event' | 'unhandledrejection' | 'manual'
  timestamp: number
}

По source можно быстро понять природу ошибки без разбора стектрейса:

  • 'render' — синхронный сбой в рендере или синхронном setup();
  • 'async'async setup(), упавший после await. Vue репортит и синхронный, и асинхронный setup() с абсолютно одинаковой строкой lifecycleHook: 'setup function' — различить их по одной этой строке невозможно, поэтому пакет дополнительно проверяет, является ли сама функция setup компонента AsyncFunction;
  • 'event' — упавший Vue-обработчик v-on;
  • 'unhandledrejection' — только от useGlobalErrorCapture();
  • 'manual' — дефолт для captureError(), если source не указан явно.

Чем это лучше «просто onErrorCaptured» и встроенного NuxtErrorBoundary

onErrorCaptured напрямую NuxtErrorBoundary vue-error-boundary-kit
Fallback UI пишете каждый раз заново #error-слот scoped fallback-слот: error, reset, retry, retryCount, canRetry
Retry / сброс вручную вручную resetKeys, resetOnPropsChange, maxRetries из коробки
Репортинг вручную вручную 5 адаптеров, tree-shaken по одному, гарантия единственного репорта во вложенных boundary
Программный API useErrorBoundary()
То, что errorCaptured не видит useGlobalErrorCapture(), отдельная точка входа
Вес 0 часть Nuxt ~1.8 KB gzip ядро, всё остальное — по требованию

NuxtErrorBoundary никуда не девается и не заменяется — он остаётся правильным выбором для страничного error.vue и для проектов, где вам не нужны retry-счётчики, дедуп репортов и адаптеры под конкретные сервисы. Разница именно в объёме встроенной логики вокруг одного и того же onErrorCaptured.


SSR — честно про границы возможного

<ErrorBoundary> безопасен для SSR в том смысле, который реально важен: упавший компонент никогда не роняет renderToString целиком и не превращается в 500-ю страницу вместо нужной. Соседний контент рендерится нормально, error-событие и reporter отрабатывают на сервере точно так же, как на клиенте.

Но есть нюанс, который стоит знать заранее, а не узнавать в проде. На клиенте onErrorCaptured меняет реактивное состояние — и Vue делает полноценный второй проход рендера, в котором вместо упавшего контента честно рисуется fallback. У серверного рендерера такого второго прохода нет физически: к моменту, когда потомок падает, render() самого boundary уже отработал и вернул готовый результат — переиграть его задним числом нечем. Добиться пиксель-идеального fallback-HTML прямо в первом ответе сервера можно было бы либо внутренними API рендерера, либо повторным выполнением setup() упавшего поддерева — и то, и другое я сознательно не стал делать, потому что это ломает совместимость с будущим Vapor Mode: компонент использует исключительно официальные onErrorCaptured/h(), ничего из внутренностей текущего VDOM-рендерера.

На практике это означает: упавший на сервере кусок в первом байте HTML придёт пустым местом, а не текстом fallback-слота — но при этом гидратация на клиенте всегда сходится к корректному, интерактивному состоянию, даже если сервер и клиент разошлись во мнениях (например, запрос упал только на сервере из-за временной проблемы с БД, а к моменту гидратации на клиенте уже успешно отработал повторно). Это ровно тот же компромисс, на который идёт любой конкурирующий вариант — включая встроенный NuxtErrorBoundary, который держится на том же самом onErrorCaptured и упирается в те же самые архитектурные границы Vue.


Сценарии в реальных проектах

1. Дашборд из независимых виджетов

Классическая admin-панель: десяток карточек с графиками, каждая тянет данные из своего эндпоинта. Если один график получил кривые данные и упал в рендере — соседние карточки не должны исчезать вместе с ним, а массовое падение по общей причине (прилёг один бэкенд-сервис) не должно устроить шторм одинаковых инцидентов в Sentry.

vue Copy
<script setup lang="ts">
import { ErrorBoundary } from 'vue-error-boundary-kit'
import { createSentryReporter } from 'vue-error-boundary-kit/adapters/sentry'
import { createRateLimitedReporter } from 'vue-error-boundary-kit/adapters/rate-limit'
import { createErrorHistory, ErrorHistoryPanel } from 'vue-error-boundary-kit/devtools'
import * as Sentry from '@sentry/vue'

const history = createErrorHistory({ limit: 30 })

const reporter = createRateLimitedReporter(
  [createSentryReporter({ client: Sentry }), history.record],
  { maxPerWindow: 5, windowMs: 30_000 },
)

const widgets = [
  { id: 'revenue', component: RevenueChart },
  { id: 'traffic', component: TrafficChart },
  { id: 'churn', component: ChurnChart },
]
</script>

<template>
  <div class="dashboard-grid">
    <ErrorBoundary v-for="widget in widgets" :key="widget.id" :reporter="reporter" :max-retries="2">
      <template #default>
        <component :is="widget.component" />
      </template>
      <template #fallback="{ error, retry, canRetry }">
        <div class="widget-error">
          <p>Виджет временно недоступен</p>
          <button v-if="canRetry" @click="retry">Обновить</button>
        </div>
      </template>
    </ErrorBoundary>

    <ErrorHistoryPanel v-if="isDevMode" :history="history" class="debug-panel" />
  </div>
</template>

Если у API прилёг конкретный эндпоинт и все 10 виджетов посыпались одновременно — rate-limit не даст Sentry захлебнуться десятками одинаковых по сути инцидентов за минуту, а debug-панель истории даёт разработчику мгновенный обзор произошедшего прямо на странице, без переключения в Sentry.

2. Форма оформления заказа с шагами

Многошаговая форма, где отдельный шаг (например, виджет проверки адреса через сторонний API) может упасть, но не должен обнулять уже введённые данные пользователя на предыдущих шагах — и должен сам оживать при переходе между шагами, без ручного вмешательства.

vue Copy
<script setup lang="ts">
import { ErrorBoundary } from 'vue-error-boundary-kit'
import { createConsoleReporter } from 'vue-error-boundary-kit/adapters/console'

const currentStep = ref('address')
const reporter = createConsoleReporter({ prefix: '[checkout]' })

function handleStepError(error) {
  analytics.track('checkout_step_failed', { step: currentStep.value, message: error.message })
}
</script>

<template>
  <div class="checkout">
    <StepIndicator :current="currentStep" />

    <ErrorBoundary :reset-keys="[currentStep]" :reporter="reporter" @error="handleStepError">
      <template #default>
        <StepAddress v-if="currentStep === 'address'" />
        <StepDelivery v-else-if="currentStep === 'delivery'" />
        <StepPayment v-else-if="currentStep === 'payment'" />
      </template>
      <template #fallback="{ error, reset }">
        <div class="step-error">
          <p>Не удалось загрузить этот шаг: {{ error.message }}</p>
          <button @click="reset">Попробовать снова</button>
        </div>
      </template>
    </ErrorBoundary>
  </div>
</template>

resetKeys="[currentStep]" — при переходе на другой шаг boundary автоматически сбрасывается и заново пробует отрендерить нужный компонент шага, без ручного reset() в навигационном коде. Данные, введённые на предыдущих шагах (хранятся отдельно, вне boundary — например, в Pinia-сторе), никуда не пропадают: падает и восстанавливается только содержимое текущего шага, а не вся форма целиком.

3. Встраиваемый на чужие сайты виджет

Виджет чата или рекомендаций, который клиенты встраивают на свои сайты через <script>. Здесь особенно важно: ошибка внутри виджета не должна быть заметна на хост-странице вообще никак — ни визуально, ни через утечку в консоль или в глобальный window.onerror хост-сайта, — а диагностика должна долетать именно к вам, а не потеряться в чужой консоли, до которой у вас нет доступа.

ts Copy
import { createApp, h } from 'vue'
import { ErrorBoundary } from 'vue-error-boundary-kit'
import { useGlobalErrorCapture } from 'vue-error-boundary-kit/global-capture'
import { createHttpReporter } from 'vue-error-boundary-kit/adapters/http'
import { createRateLimitedReporter } from 'vue-error-boundary-kit/adapters/rate-limit'
import WidgetRoot from './WidgetRoot.vue'

const reporter = createRateLimitedReporter(
  createHttpReporter({ endpoint: 'https://telemetry.mywidget.io/errors', batchInterval: 5000 }),
  { maxPerWindow: 20, windowMs: 60_000 },
)

// Ловим и то, что вне Vue-дерева — например, обработчик на кастомном элементе виджета,
// повешенный напрямую через addEventListener в обход Vue
useGlobalErrorCapture({
  reporter,
  shouldCatch: (e) => !e.message.includes('ResizeObserver loop'), // шум расширений/браузера — не наше
})

const app = createApp({
  render: () =>
    h(ErrorBoundary, { reporter, isolate: true }, {
      default: () => h(WidgetRoot),
      fallback: () => null, // тихо схлопываемся — ничего не показываем на чужой странице
    }),
})

app.mount('#my-widget-root')

isolate: true (дефолт, можно даже не указывать явно — оставил в примере для наглядности) гарантирует, что ошибка не всплывёт дальше и не заденет остальной JS на хост-странице. fallback: () => null — сознательное решение: лучше виджет тихо исчезнет, чем покажет чужому пользователю красную плашку с текстом на непонятном ему языке или, того хуже, со стектрейсом. При этом инцидент долетает в вашу телеметрию через http-адаптер с батчингом, защищённый rate-limit'ом от шторма при массовом сбое сразу у множества клиентов.


Итого

vue-error-boundary-kit не пытается быть чем-то большим, чем нужно: нет своего языка конфигурации, нет привязки к Nuxt, нет скрытой магии поверх onErrorCaptured. Компонент, composable, пять адаптеров репортинга, обёртка с дедупом и лёгкая debug-панель — 1.8 KB ядра, остальное тянется только если реально используется. TypeScript-типы выведены для всех пропсов, событий и опций, покрытие тестами — 98%, включая честно описанные (а не замолчанные) границы того, что физически возможно в SSR.

NPM: https://www.npmjs.com/package/vue-error-boundary-kit
GitHub: https://github.com/macrulezru/vue-error-boundary-kit

Читать далее

rest-pipeline-js 2.0.0 - распределённый rate limiter, circuit breaker, трассировка запросов и идемпотентность

17.07.2026

Мажорный релиз 2.0.0: rate limiter и circuit breaker научились работать в кластере через общий backend (например, Redis), запросы можно трассировать по W3C Trace Context, а мутирующие запросы — безопасно ретраить с помощью idempotency-ключей. Заодно поймал и починил баг, из-за которого пакет вообще не грузился нативным Node ESM.

Метки
rest-pipeline-jstypescriptnodejsmicroservicesopensource

vue-virtual-scroller-kit 0.2: большое обновление — RTL, кастомный скроллбар и ещё десяток новых возможностей

17.07.2026

Самое большое обновление vue-virtual-scroller-kit с момента первого релиза: выбор строк по клику и Shift+click, асинхронный поиск в VirtualSelect, полное управление колонками таблицы, горизонтальные списки, отслеживание видимости через IntersectionObserver, кастомный скроллбар, RTL из коробки, а также motion blur, sticky-заголовки групп и ещё несколько фич поменьше. В посте — подробный разбор каждой с примерами и три сценария из реальных проектов.

Метки
vue3frontendvirtual-scrolltypescriptopensource