vue-error-boundary-kit 0.3.0: восемь новых способов не терять ошибки

20.08.2026
vue-error-boundary-kit 0.3.0: восемь новых способов не терять ошибки

vue-error-boundary-kit — Vue-пакет для error boundary, который я выпустил пару недель назад: декларативный <ErrorBoundary>, useErrorBoundary(), пять адаптеров репортинга, дедуп и rate-limit против шторма ошибок. Он честно ловит всё, что бросает Vue-компонент во время рендера, setup() и скомпилированных обработчиков v-on. Но стоило начать использовать его в реальных проектах — оказалось, что «поймать ошибку» это только половина истории.

useQuery() из TanStack Query видит вызов retry() на boundary, честно перерисовывает дерево — и тут же падает снова, потому что сам query остался в кэше библиотеки в состоянии error и мгновенно перебрасывает ту же самую, уже устаревшую ошибку, даже не пытаясь сходить в сеть заново. Роут со сломанным navigation guard вообще не долетает до onErrorCaptured — он падает раньше, чем начинается рендер компонента, в совершенно другом месте жизненного цикла vue-router. В Nuxt-проекте для каждого нового boundary приходится руками писать одни и те же три строчки импорта. А нестабильный сторонний API, который иногда просто нужно попросить подождать секунду перед повтором, вообще никак не был предусмотрен — retry() был мгновенным и безусловным.

Версия 0.3.0 закрывает все эти стыки разом: полноценный Nuxt-модуль, интеграции с TanStack Query и vue-router, адаптер под OpenTelemetry, компонент, совмещающий <Suspense> с boundary, backoff для retry, встроенные breadcrumbs и публичные тестовые утилиты — восемь новых точек входа, каждая опциональная и изолированная. Заодно навёл порядок в инфраструктуре: CI на GitHub Actions, публикация по тегу, и один поучительный баг с версией Node, о котором тоже расскажу. Опубликованный тарбол теперь весит 101 файл против 57 на момент первого релиза, тестов — 133 против 84. При этом ядро почти не сдвинулось с места, и это тоже не случайность, а результат нескольких сознательных решений «не пихать в core».

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


Быстрый обзор

Фича Точка входа Что решает
Nuxt-модуль vue-error-boundary-kit/nuxt Авто-регистрация <ErrorBoundary> и авто-импорт composable в Nuxt-проекте
useNuxtErrorBoundary() vue-error-boundary-kit/nuxt/runtime Ловит то, что не видит ни один компонентный boundary — vue:error/app:error
TanStack Query vue-error-boundary-kit/tanstack-query retry(), который реально перезапрашивает данные, а не мгновенно падает снова
vue-router vue-error-boundary-kit/router Ошибки в navigation guard'ах и async route-компонентах
OpenTelemetry-адаптер vue-error-boundary-kit/adapters/otel Репортинг ошибок как span'ов трейсинга, без прямой зависимости от @opentelemetry/api
<AsyncBoundary> vue-error-boundary-kit/async-boundary <Suspense> и <ErrorBoundary> одним компонентом, три слота
Breadcrumbs vue-error-boundary-kit/adapters/breadcrumbs След из событий перед ошибкой, опционально прикладываемый к репорту
Retry с backoff vue-error-boundary-kit/retry-backoff Растущая пауза перед повтором для нестабильных внешних API
Тестовые утилиты vue-error-boundary-kit/testing Готовые throw-компоненты и test-double reporter, framework-agnostic

Nuxt-модуль: <ErrorBoundary> без единого импорта

Раньше в Nuxt-проекте <ErrorBoundary> подключался так же, как в обычном Vue-приложении — руками, в каждом файле, где он нужен. В 0.3.0 есть настоящий Nuxt-модуль, а не просто раздел документации про «как подружить с Nuxt»:

ts Copy
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['vue-error-boundary-kit/nuxt'],
})

Этого достаточно, чтобы <ErrorBoundary> стал глобальным компонентом, а useErrorBoundary, useGlobalErrorCapture и useNuxtErrorBoundary (ниже) — авто-импортируемыми composable, без единой строчки import в файлах приложения. Модуль принимает две опции:

Опция Тип По умолчанию Что делает
component boolean true Регистрировать <ErrorBoundary> глобальным компонентом
autoImports boolean true Авто-импортировать три composable выше
ts Copy
export default defineNuxtConfig({
  modules: ['vue-error-boundary-kit/nuxt'],
  errorBoundaryKit: {
    component: true,
    autoImports: true,
  },
})

@nuxt/kit при этом — опциональный peer, а не зависимость: он никогда не попадает в бандл приложения, потому что уже есть в любой Nuxt-установке транзитивно через сам nuxt.

Отдельно — useNuxtErrorBoundary(), composable поверх useErrorBoundary(), подключённый к собственным хукам Nuxt vue:error и app:error. Это ловит то, что не видит ни один компонентный <ErrorBoundary> в принципе: ошибку рендера, которая дошла до корня приложения необработанной (то есть все boundary в дереве либо отсутствовали, либо пропустили её через shouldCatch), и весь fatal-flow самого Nuxt — showError()/createError(), вызываемые, например, из серверного middleware. Оба хука работают изоморфно, то есть одинаково на сервере и на клиенте:

vue Copy
<!-- app.vue -->
<script setup lang="ts">
const { error, hasError } = useNuxtErrorBoundary({ reporter: sentryReporter })
</script>

<template>
  <ErrorBoundary :reporter="sentryReporter">
    <NuxtPage />
  </ErrorBoundary>
</template>

По дороге нашёл неочевидный баг с типизацией опций модуля — то есть неправильно предположил, что раз defineNuxtModule<ModuleOptions>() типизирован, тайпинг опций в чужом nuxt.config.ts появится автоматически. Не появляется: собственный механизм авто-подключения типов у @nuxt/kit резолвит запись modules: [...] до ближайшего package.json, то есть всегда до корневого пакета, а не до реально импортированного сабпути /nuxt. Без явной регистрации через nuxt.hook('prepare:types', ({ references }) => references.push({ types: 'vue-error-boundary-kit/nuxt' })) опция errorBoundaryKit в чужом nuxt.config.ts осталась бы нетипизированной — IDE просто не подсказывала бы её существование, а опечатка в имени поля прошла бы тайпчек молча.

Проверял это не рассуждением о том, как «должно быть», а полным циклом: собрал пакет в npm pack, установил тарбол в чистый Nuxt 4.5.2-проект как обычный npm-пакет через настоящий exports-маппинг (а не через relative-путь до исходников), прогнал nuxi prepare, руками сверил сгенерированные .nuxt/nuxt.d.ts и .nuxt/components.d.ts — и в довершение написал nuxt.config.ts с намеренно неверным типом опции под @ts-expect-error, чтобы убедиться, что тайпчек её действительно ловит, а не просто не падает по случайности.

TanStack Query: retry, который реально перезапрашивает данные

retry() у <ErrorBoundary> — это просто «попробовать отрендерить default-слот заново». Для useQuery({ throwOnError: true }) этого недостаточно: сам query внутри TanStack Query остаётся в кэше библиотеки в состоянии error, и на следующем же рендере useQuery() снова бросает ту же самую, уже устаревшую ошибку — queryFn при этом даже не вызывается повторно, запрос в сеть не уходит. Кнопка «повторить» превращается в кнопку «показать то же самое ещё раз».

В React-экосистеме для этого специально придумали QueryErrorResetBoundary — компонент-контекст, который «сбрасывает» упавшие query прямо перед тем, как error boundary попробует отрендерить дерево заново. В @tanstack/vue-query такого примитива нет вообще — я проверял это по установленным .d.ts пакета, а не полагался на память: там просто отсутствует файл с подобным экспортом.

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

const resetErroredQueries = useQueryErrorReset()
</script>

<template>
  <ErrorBoundary :before-reset="resetErroredQueries">
    <template #default>
      <UserProfile :id="userId" />
      <!-- использует useQuery({ ..., throwOnError: true }) -->
    </template>
    <template #fallback="{ error, retry }">
      <ErrorState :message="error.message" @retry="retry" />
    </template>
  </ErrorBoundary>
</template>

useQueryErrorReset(options?) возвращает синхронную функцию, которая сбрасывает через queryClient.resetQueries({ predicate }) все query, находящиеся именно в состоянии ошибки — предикат фильтрует по query.state.status === 'error', остальной кэш не трогается. Опционально принимает свой queryClient (по умолчанию — из контекста через useQueryClient()) и id для multi-client-приложений. Подключается в beforeReset, а не в @retry, потому что beforeReset вызывается на любой сброс, ручной или автоматический — и срабатывает строго перед тем, как boundary заново отрендерит default-слот: к моменту повторного рендера query уже не в состоянии error, и queryFn реально выполняется заново.

Проверял это не юнит-тестом на моках query-клиента, а настоящим useQuery() под настоящим смонтированным <ErrorBoundary>: счётчик вызовов queryFn на клике по кнопке retry реально идёт 1 → 2, а не остаётся на месте, как было бы без сброса кэша.

vue-router: ошибки, которые структурно не доходят до onErrorCaptured

У ошибок в navigation guard'ах и в асинхронных route-компонентах (component: () => import('./Heavy.vue'), если сеть подвела и чанк не догрузился) есть общее свойство: они происходят вне рендер/setup()-цикла компонента, поэтому onErrorCaptured их не видит физически — не потому что кто-то забыл их туда прокинуть, а потому что такого канала для этого класса ошибок в самом Vue не существует в принципе. У vue-router для этого есть собственный router.onError().

ts Copy
import { useRouterErrorBoundary } from 'vue-error-boundary-kit/router'
vue Copy
<!-- App.vue -->
<script setup lang="ts">
const { error, hasError, reset } = useRouterErrorBoundary({
  reporter: sentryReporter,
  onError: (e) => analytics.track('navigation_failed', { message: e.message }),
})
</script>

Composable — useErrorBoundary(), подключённый к router.onError(), с автоматической отпиской через onScopeDispose при удалении component scope (тот же паттерн, что уже был у useGlobalErrorCapture()). Принимает ровно те же опции, что и useErrorBoundary(): onError, beforeReset, reporter, reportContext, internalErrorPrefix.

Маленький, но показательный факт из сверки API перед реализацией: последняя версия vue-router на момент написания — 5.2.0, а не 4.x, как я по инерции ожидал, планируя эту фичу. Сигнатуру onError проверял вживую, читая установленные .d.ts, а не по памяти — благо она не изменилась между мажорами и подтверждена собственной документацией пакета прямо в комментарии к методу: «Adds an error handler that is called every time a non caught error happens during navigation. This includes errors thrown synchronously and asynchronously, errors returned or passed to next in any navigation guard, and errors occurred when trying to resolve an async component that is required to render a route». Проверил оба случая из этого описания реальными тестами — на настоящем createRouter() с памятью вместо истории браузера, с гвардом, бросающим исключение, и с route-компонентом, чей динамический импорт реджектится.

OpenTelemetry-адаптер — и почему он не импортирует @opentelemetry/api

ts Copy
import { trace, SpanStatusCode } from '@opentelemetry/api'
import { createOtelReporter } from 'vue-error-boundary-kit/adapters/otel'

const reporter = createOtelReporter({
  tracer: trace.getTracer('my-app'),
  spanName: 'error-boundary-kit.error',       // по умолчанию тоже так
  attributes: { team: 'frontend' },           // доп. атрибуты на каждый span
})

На каждый report() адаптер стартует новый span через переданный Tracer, вызывает recordException() и setStatus({ code: ERROR }), прикладывает поля CapturedError (source, componentName, lifecycleHook, timestamp, плюс context из вызова report()) как атрибуты span'а, и завершает его.

Как и адаптеры под Sentry/Bugsnag/LogRocket, @opentelemetry/api пакетом не импортируется — но тут причина решения интереснее, чем в остальных трёх случаях, и стоит того, чтобы разобрать её отдельно. У Sentry/Bugsnag/LogRocket структурная типизация не стоит вообще ничего: пользователь так или иначе уже инициализирует их SDK где-то в приложении для других целей (алерты, session replay), реального SDK-клиента без этого не получить в принципе, значит и «сэкономленных» байт с точки зрения бандла тут просто не существует. С OpenTelemetry всё не так однозначно, потому что @opentelemetry/api теоретически можно было бы просто импортировать напрямую — он позиционируется как лёгкий, безопасный к использованию даже без настроенного backend'а. Я решил не гадать, а замерить: собрал минимальное реальное использование (trace.getActiveSpan() + recordException) через esbuild --bundle --minify7.8 KB в сыром виде, ~2.8 KB gzip. Не огромно, но и не ноль — а значит, оставаться на структурной типизации, а не тащить API-пакет напрямую в зависимости пакета, было осознанным решением по результатам измерения, а не автоматическим копированием паттерна от других трёх адаптеров.

Если нужно приложить ошибку к уже активному span'у, а не создавать отдельный, изолированный от текущего трейса — это решается композицией снаружи, без единого изменения в самом адаптере:

ts Copy
const reporter = createOtelReporter({
  tracer: { startSpan: () => trace.getActiveSpan() ?? realTracer.startSpan('fallback') },
})

<AsyncBoundary><Suspense> и <ErrorBoundary> одним компонентом

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

<template>
  <AsyncBoundary :reset-keys="[userId]" :max-retries="3">
    <template #default>
      <UserProfile :id="userId" />
      <!-- async setup() / асинхронные компоненты допустимы -->
    </template>
    <template #loading>
      <Spinner />
    </template>
    <template #fallback="{ error, retry, canRetry }">
      <ErrorState :message="error.message" :can-retry="canRetry" @retry="retry" />
    </template>
  </AsyncBoundary>
</template>

Тут стоит быть честным перед собой и перед читателем: <Suspense> внутри <ErrorBoundary> и раньше работал, если вложить их вручную — это проверялось тестами ещё на самой первой версии пакета. <AsyncBoundary> не открывает новый канал перехвата ошибок и не ловит ничего, что не ловилось бы и раньше. Вся его добавленная стоимость — убрать необходимость каждый раз собирать эту же самую двухуровневую вложенность руками и синхронизировать пропы между <Suspense> и <ErrorBoundary>. Под капотом это буквально <ErrorBoundary>, оборачивающий <Suspense> (#default слот Suspense — это default boundary, #fallback слот Suspense — это loading boundary), с полным проксированием пропсов, событий (error, reset) и exposed API (error, hasError, retryCount, canRetry, reset(), retry()) через template-ref на внутренний <ErrorBoundary>. Вся логика перехвата и retry по-прежнему живёт ровно в одном месте — не задублирована, не переизобретена заново для «асинхронного» случая.

Отдельная деталь про размещение: изначально добавил <AsyncBoundary> прямо в основной index.ts — компонент маленький, реюзает уже загруженный <ErrorBoundary>, соблазн был велик. Но реальный замер gzip-размера собранного dist/index.mjs до и после показал: из-за того, что <AsyncBoundary> и core теперь ссылаются на один и тот же ErrorBoundary.vue, Rollup выносит его в общий чанк — и размер index.mjs для всех потребителей пакета, включая тех, кто Suspense вообще не использует, заметно подрастает. Вынес в отдельную точку входа (vue-error-boundary-kit/async-boundary) — итоговый рост core-бюджета от этого решения свёлся к паре сотен байт вместо ощутимого куска, а сам компонент по-прежнему бесплатен, пока явно не импортирован.

ts Copy
import { createBreadcrumbTrail, withBreadcrumbs } from 'vue-error-boundary-kit/adapters/breadcrumbs'

const trail = createBreadcrumbTrail({ limit: 20 }) // по умолчанию тоже 20

router.afterEach((to) => {
  trail.addBreadcrumb({ category: 'navigation', message: `→ ${to.fullPath}` })
})

document.addEventListener('click', (e) => {
  const target = (e.target as HTMLElement).closest('[data-track]')
  if (target) trail.addBreadcrumb({ category: 'ui.click', message: target.dataset.track! })
})

const reporter = withBreadcrumbs(sentryReporter, { trail })
vue Copy
<ErrorBoundary :reporter="[reporter, trail.record]"></ErrorBoundary>

Принципиально: ничего не инструментируется автоматически. Ни клики, ни навигация сами по себе не попадают в трейл, пока явно не вызвать addBreadcrumb({ category, message, data? }) — это тот же принцип, что и у useGlobalErrorCapture(): пакет ничего не подписывает на DOM без явного согласия, и уж тем более не начинает сам решать, какие клики достаточно важны, чтобы их логировать. trail.record — такой же ErrorReporter, как консольный или HTTP: его можно передать вторым элементом массива reporter, и тогда прошлые пойманные ошибки тоже автоматически становятся breadcrumbs для следующих — то есть если приложение упало дважды за минуту, второй репорт будет содержать в трейле упоминание о первом падении.

withBreadcrumbs(reporter, { trail, contextKey?, internalErrorPrefix? }) оборачивает любой reporter или массив reporter'ов, подмешивая текущее содержимое трейла в context на каждый вызов report() под ключом contextKey (по умолчанию 'breadcrumbs') — можно переименовать, если у целевого сервиса уже занято это поле.

Порядок записей в трейле — хронологический, от старых к новым, специально в противоположность createErrorHistory() (там, наоборот, самые свежие — первыми, это удобнее для debug-панели, которую листаешь сверху вниз). Разница задокументирована прямо в JSDoc рядом с полем entries, чтобы не путать при работе с обоими механизмами одновременно — благо они всё равно решают разные задачи и обычно используются вместе, а не вместо друг друга.

Retry с экспоненциальной задержкой

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

ts Copy
import { createBackoffRetry } from 'vue-error-boundary-kit/retry-backoff'

const boundary = useTemplateRef('boundary')
const backoff = createBackoffRetry(boundary, {
  baseDelayMs: 1000,   // задержка перед первой попыткой (по умолчанию тоже 1000)
  factor: 2,            // множитель на каждую попытку — экспоненциальный рост (по умолчанию тоже 2)
  maxDelayMs: 30_000,   // потолок задержки (по умолчанию тоже 30 секунд)
})
vue Copy
<ErrorBoundary ref="boundary" :max-retries="5">
  <template #fallback="{ error, canRetry }">
    <button :disabled="!canRetry || backoff.isPending.value" @click="backoff.retry()">
      {{ backoff.isPending.value ? 'Повторяю…' : 'Повторить' }}
    </button>
  </template>
</ErrorBoundary>

Задержка на каждый вызов считается заново от текущего retryCount самого boundary (baseDelayMs * factor ** retryCount, с потолком maxDelayMs) — то есть каждая следующая попытка ждёт дольше предыдущей автоматически, без ручного подсчёта где-то во внешнем состоянии компонента. backoff.isPending — реактивный Ref<boolean>, удобно дизейблить кнопку и менять текст, пока идёт отсчёт. backoff.cancel() снимает запланированный повтор, если он больше не нужен (например, пользователь ушёл со страницы). Отдельно проверял тестами на vi.useFakeTimers() неочевидный, но важный для корректности момент: колбэк таймера перечитывает boundaryRef.value заново в момент срабатывания, а не захватывает снэпшот на момент клика — если boundary успел размонтироваться за время ожидания, повторный retry() на уже несуществующем инстансе просто не вызовется, а не упадёт с ошибкой обращения к null.

Сознательно не стал делать это пропом <ErrorBoundary> — не каждому сценарию нужна задержка, а раздувать core-API условной логикой ради части случаев не хотелось. Вместо этого — отдельная утилита, работающая поверх уже существующего exposed API (retry/retryCount), которую можно применить и к <ErrorBoundary>, и к <AsyncBoundary> без единого изменения в них самих — оба компонента отдают через template-ref одинаковую форму { retry, retryCount }.

Тестовые утилиты — то, что все и так копируют по кругу

Внутри пакета с самого начала жили компоненты, бросающие исключение по требованию — для собственных тестов <ErrorBoundary>. Оказалось, что ровно то же самое пишет любой, кто тестирует свой код поверх этого пакета: свой Boom.vue, свой мок reporter'а, своя фикстура CapturedError. Вынес в публичную точку входа.

Экспорт Что делает
ThrowInRender Бросает в render(), когда проп shouldThrow (по умолчанию true); при false рендерит .ok с текстом из message
ThrowInSetup Бросает синхронно в setup() — классифицируется как source: 'render'
ThrowInAsyncSetup Бросает после await в async setup() — классифицируется как source: 'async'; требует <Suspense>/<AsyncBoundary> вокруг
ThrowAbortError Бросает AbortError-DOMException, как отменённый fetch() — для тестов shouldCatch-фильтров
makeCapturedError(overrides?) Фикстура CapturedError со всеми обязательными полями по умолчанию
createRecordingReporter() Test-double ErrorReporter: { calls, report(), reset() }
ts Copy
import { mount } from '@vue/test-utils'
import { h, nextTick } from 'vue'
import { ErrorBoundary } from 'vue-error-boundary-kit'
import { ThrowInRender, createRecordingReporter } from 'vue-error-boundary-kit/testing'

const reporter = createRecordingReporter()
const wrapper = mount(ErrorBoundary, {
  props: { reporter },
  slots: {
    default: () => h(ThrowInRender, { message: 'boom' }),
    fallback: ({ error }) => h('div', { class: 'fallback' }, error.message),
  },
})
await nextTick() // onErrorCaptured меняет реактивное состояние синхронно, но DOM обновляется на следующем тике

expect(wrapper.find('.fallback').text()).toBe('boom')
expect(reporter.calls[0]?.error.source).toBe('render')

createRecordingReporter() намеренно не завязан ни на vi.fn(), ни на jest.fn() — просто плоский массив calls, куда складываются пары { error, context } в порядке вызовов, поэтому одинаково работает и под Vitest, и под Jest, без лишней зависимости, зашитой в публичный API.

Именно на строчке await nextTick() в примере выше я сам один раз наступил на грабли, когда писал тесты к самим этим утилитам. Реактивное состояние (error.value) onErrorCaptured меняет синхронно — но перерисовку DOM Vue батчит на следующий тик, а не делает её сразу же внутри того же вызова. Без nextTick() wrapper.find('.fallback') сразу после mount() оказывается пустым DOMWrapper, хотя ошибка уже поймана и reporter.calls уже не пуст — потому что состояние обновилось, а разметка ещё нет. Мелочь, но именно такая мелочь незаметно ломает половину тестов, если её не знать заранее.


Что не вошло — и почему

Отдельно рассматривал вкладку в Nuxt DevTools через @nuxt/devtools-kit — казалось, что раз Nuxt-модуль уже есть, добавить туда живую панель истории ошибок будет почти бесплатным дополнением: DevTools работают только в nuxi dev и не попадают в продакшен-бандл, в отличие от браузерного расширения Vue Devtools (от которого я отказался ещё в 0.1.0 из-за реального веса @vue/devtools-api, ~20 KB gzip).

При проверке настоящего API @nuxt/devtools-kit картина оказалась сложнее. addCustomTab() действительно существует, и один из трёх вариантов содержимого вкладки — vnode — но он явно документирован как «должен быть статическим и сериализуемым». А createErrorHistory()'s реактивное состояние живёт в браузерном инстансе конкретного приложения — то есть в клиентском рантайме, — тогда как регистрация вкладки происходит из setup() Nuxt-модуля, который выполняется в Node.js на этапе сборки/запуска dev-сервера. Это два физически разных процесса без общей памяти. Единственный вариант вкладки, реально поддерживающий живые, постоянно обновляющиеся данные — iframe, указывающий на отдельно обслуживаемый dev-server route — а это уже требует собственного RPC-моста между клиентом и devtools-сервером (extendServerRpc/iframe-client), то есть по сути отдельную мини-SPA и отдельный протокол обмена сообщениями, а не пару строчек конфигурации.

Добавило сомнений и состояние самой экосистемы: на момент проверки latest для @nuxt/devtools-kit на npm указывал на alpha-версию мажорного перехода (4.0.0-alpha.11), стабильная линейка застряла на 3.4.1. Строить новую фичу поверх API, которое прямо сейчас меняется под ногами, — плохая идея сама по себе, вне зависимости от объёма работы. Решил не реализовывать: несоразмерная сложность против пользы, когда <ErrorHistoryPanel> и так закрывает практическую задачу отладки прямо в приложении, без DevTools вообще.

Заодно: CI, публикация и один баг с версией Node

Помимо самих фич, 0.3.0 обзавёлся CI на GitHub Actions (lint, typecheck, тесты с покрытием, сборка на каждый push/PR по двум версиям Node) и workflow автоматической публикации по git-тегу. И на первом же реальном прогоне в CI матрица немедленно упала — на Node 18.x с загадочной ошибкой из недр rolldown (сборщик, на который Vite 8 переехал под капотом): node:util не экспортирует styleText.

Разобрался: styleText в node:util появился только в Node 20.12/22, в восемнадцатой ветке его нет вообще ни в одной версии, а Vite 8 и rolldown сами честно объявляют в своём package.json требование engines.node: "^20.19.0 || >=22.12.0" — просто это никогда не проверялось на практике, потому что вся моя локальная разработка велась на одной машине с одной, современной версией Node. Не поверил на слово документации — на машине нашёлся nvm с несколькими установленными версиями, и я реально прогнал тесты на Node 18.12.0 (падает с идентичной ошибкой), 20.10.0 (тоже падает — ниже нужного порога 20.19) и 22.23.2 (111 из 111 тестов зелёные). Поправил engines.node в package.json под реальный порог инструментов и убрал 18.x из CI-матрицы — с явной оговоркой в CONTRIBUTING.md, что это ограничение только для сборки из исходников, а не для того, как опубликованный dist/ работает в чужом приложении: там нет ничего Node-20-специфичного, обычный ES2020.


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

1. Nuxt SSR-приложение с TanStack Query и трейсингом

Панель админки на Nuxt: данные тянутся через @tanstack/vue-query, наблюдаемость — через собственный OpenTelemetry-коллектор, а на верхнем уровне нужна страховка от всего, что может проскочить мимо локальных boundary на страницах. Три новые интеграции работают вместе без единой ручной склейки между ними:

ts Copy
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['vue-error-boundary-kit/nuxt'],
})
vue Copy
<!-- app.vue -->
<script setup lang="ts">
import { trace } from '@opentelemetry/api'
import { createOtelReporter } from 'vue-error-boundary-kit/adapters/otel'

const reporter = createOtelReporter({ tracer: trace.getTracer('admin-panel') })
useNuxtErrorBoundary({ reporter }) // auto-import из модуля
</script>

<template>
  <ErrorBoundary :reporter="reporter">
    <NuxtPage />
    <template #fallback="{ error, retry }">
      <ErrorState :message="error.message" @retry="retry" />
    </template>
  </ErrorBoundary>
</template>
vue Copy
<!-- pages/customers/[id].vue -->
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import { trace } from '@opentelemetry/api'
import { createOtelReporter } from 'vue-error-boundary-kit/adapters/otel'
import { useQueryErrorReset } from 'vue-error-boundary-kit/tanstack-query'

const route = useRoute() // Nuxt auto-import
const reporter = createOtelReporter({ tracer: trace.getTracer('admin-panel') })
const resetErroredQueries = useQueryErrorReset()

const { data } = useQuery({
  queryKey: ['customer', route.params.id],
  queryFn: () => fetchCustomer(route.params.id),
  throwOnError: true,
})
</script>

<template>
  <ErrorBoundary :reporter="reporter" :before-reset="resetErroredQueries">
    <template #default>
      <CustomerDetails :customer="data" />
    </template>
    <template #fallback="{ error, retry }">
      <ErrorState :message="error.message" @retry="retry" />
    </template>
  </ErrorBoundary>
</template>

Ошибка рендера конкретной страницы уходит в OTel через локальный reporter, а retry на этой странице реально перезапрашивает данные клиента через useQueryErrorReset — вместо того, чтобы просто ещё раз показать ту же самую ошибку из кэша. А всё, что умудрилось проскочить мимо любого boundary на любой странице (включая провалившийся navigation guard где-то в middleware), долетает через useNuxtErrorBoundary() в корне и всё равно попадает в тот же самый трейсинг.

2. SPA на vue-router с нестабильным внешним API

Дашборд без Nuxt, с собственным vue-router, где один из виджетов дёргает капризный сторонний API курсов валют, который время от времени просто не отвечает несколько секунд подряд.

vue Copy
<!-- App.vue -->
<script setup lang="ts">
import { useRouterErrorBoundary } from 'vue-error-boundary-kit/router'

useRouterErrorBoundary({ reporter: sentryReporter }) // ловит сломанные guard'ы и провал lazy-роутов
</script>
vue Copy
<!-- ExchangeRatesWidget.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { ErrorBoundary } from 'vue-error-boundary-kit'
import { createBackoffRetry } from 'vue-error-boundary-kit/retry-backoff'

const boundary = useTemplateRef('boundary')
const backoff = createBackoffRetry(boundary, { baseDelayMs: 2000, maxDelayMs: 20_000 })
</script>

<template>
  <ErrorBoundary ref="boundary" :max-retries="5">
    <template #default>
      <RatesTable />
    </template>
    <template #fallback="{ canRetry }">
      <button :disabled="!canRetry || backoff.isPending.value" @click="backoff.retry()">
        {{ backoff.isPending.value ? 'Жду перед повтором…' : 'Обновить курсы' }}
      </button>
    </template>
  </ErrorBoundary>
</template>

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

3. Надёжные тесты error-handling логики в CI

Чек-аут с несколькими шагами, где отдельный шаг (например, проверка адреса через внешний сервис) может упасть — и это должно быть покрыто тестами так же основательно, как и happy path, включая проверку, что диагностическая информация (breadcrumbs) действительно долетает до reporter'а вместе с ошибкой.

ts Copy
import { mount } from '@vue/test-utils'
import { h, nextTick } from 'vue'
import { ErrorBoundary } from 'vue-error-boundary-kit'
import { createBreadcrumbTrail, withBreadcrumbs } from 'vue-error-boundary-kit/adapters/breadcrumbs'
import { ThrowInRender, createRecordingReporter } from 'vue-error-boundary-kit/testing'

it('шаг адреса: падение не теряет breadcrumbs и репортится с трейлом навигации', async () => {
  const trail = createBreadcrumbTrail()
  trail.addBreadcrumb({ category: 'navigation', message: '→ /checkout/address' })

  const inner = createRecordingReporter()
  const reporter = withBreadcrumbs(inner, { trail })

  const wrapper = mount(ErrorBoundary, {
    props: { reporter },
    slots: { default: () => h(ThrowInRender, { message: 'address service down' }) },
  })
  await nextTick()

  expect(inner.calls).toHaveLength(1)
  expect(inner.calls[0]?.error.message).toBe('address service down')
  expect(inner.calls[0]?.context?.breadcrumbs).toEqual(trail.entries.value)
})

Ни одного реального Vue-компонента шага чек-аута монтировать не пришлось — ThrowInRender полностью стоит на его месте для целей этого теста, а createRecordingReporter() даёт прямой доступ к тому, что именно долетело бы до Sentry, без необходимости поднимать сеть или мокать сам SDK. makeCapturedError() пригождается отдельно, в соседних тестах — там, где нужно проверить сам reporter или адаптер в изоляции, без монтирования компонента вообще, просто вызвав reporter.report(makeCapturedError({ ... })) напрямую.


Итого

Восемь новых точек входа за один релиз, и ядро при этом почти не сдвинулось: ~1.8 → ~2.1 KB gzip, причём даже этот небольшой рост — не из-за самих новых фич, а из-за неизбежного шаринга уже существующего кода (useErrorBoundary, ErrorBoundary.vue) между core и новыми /nuxt/runtime, /async-boundary через общие Rollup-чанки. Каждая интеграция подключается отдельно и не тянет за собой остальные: @nuxt/kit, @tanstack/vue-query, vue-router — все опциональные peer-зависимости, ни одна не попадает в бандл, пока её явно не импортировали. Тестов теперь 133 (было 84 к моменту первого релиза), покрытие — 97.4%.

Там, где реализация оказалась несоразмерно сложнее заявленной пользы — как с вкладкой в Nuxt DevTools — предпочёл честно объяснить отказ и почему именно, а не тихо выпилить пункт из планов или сделать наполовину работающую версию. Тот же принцип, что уже применялся раньше к браузерному расширению Vue Devtools: если цена ощутимо выше выгоды, честный «нет, и вот почему» — это тоже результат работы, а не её отсутствие.

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

Читать далее

vue-worker-kit 0.2.0: один воркер на все вкладки браузера

20.08.2026

В vue-worker-kit 0.2.0 добавился useSharedWorker() — один воркер на все вкладки одного источника вместо своего на каждую, плюс потоковые результаты, кэширование, ретраи с задержкой и прогрев воркера заранее. Разбираю каждую фичу с примерами, механикой под капотом, честными историями про баги, найденные уже на финальном ревью, и тремя сценариями использования пакета.

Метки
Vue 3TypeScriptWeb WorkersOpen SourceПроизводительность фронтенда

vue-image-kit 1.1.0: от art direction до собственного image-сервера

20.08.2026

В версии 1.1.0 vue-image-kit дорос с «удобного компонента для картинок» до полноценного стека: авто-детект 8 CDN, self-hosted сервер ресайза на замену CDN, Nuxt-модуль с реальным Nitro-роутом, инкрементальная сборка CLI и ещё десяток менее заметных, но полезных вещей. Ниже — подробный разбор того, что попало в релиз и почему сделано именно так.

Метки
vuevue-image-kitnuxtimage-optimizationopensource

@macrulez/vue-form-schema 0.2.2: типы из Zod, Nuxt-модуль и дискриминированные поля

21.08.2026

Новая версия @macrulez/vue-form-schema приносит сразу семь крупных возможностей: автовывод TypeScript-типов из Zod/Yup/Valibot, официальный Nuxt-модуль, три новые UI-темы, поддержку OpenAPI, маппинг серверных ошибок, плагин для Vue DevTools и дискриминированные схемы.

Метки
vuevue-form-schemanuxttypescriptopensource