Асинхронные обработчики
Обработчик триггера может быть sync или async. Асинхронные обработчики существуют ради одной причины: сценарий должен подождать что-то — fetch, чтение из IndexedDB, парсинг файла кусками, сообщение из воркера. Всё остальное (set state, диспатч действия, выпусти follow-up события) должно оставаться синхронным.
Async также меняет жизненный цикл. Sync-обработчик доходит до конца за один тик; async-обработчик имеет длительность, может быть superseded более новым прогоном и владеет AbortSignal всё окно между первым await и return.
Когда использовать async-обработчик
Заголовок раздела «Когда использовать async-обработчик»Когда сценарий не может решить, что делать, не дождавшись результата I/O.
Обработчик async, fetch получает { signal }, действие запускается только после возврата сетевого ответа. Это и есть весь паттерн — 13 строк, никаких лишних примитивов.
Когда async-обработчик не нужен
Заголовок раздела «Когда async-обработчик не нужен»Не делай обработчик async, если он ничего не await’ит. Три причины:
- Весь прогон становится промисом, который рантайм считает в полёте, что взаимодействует с
concurrency. Sync-сценарий внезапно начинает вести себя как async. - Ошибки, кинутые внутри
async, ловятся как rejection’ы, а не синхронные throws. Поведение идентичное (см. Поток ошибок), но ты теряешь более простые стек-трейсы. - Обработчик кажется “в полёте” один microtask, что может замаскировать баги, где ты ждал, что
concurrency: 'take-first'пропустит следующий прогон.
Правило большого пальца: если нет await, убирай ключевое слово async.
Получаем и пробрасываем signal
Заголовок раздела «Получаем и пробрасываем signal»ctx.signal — это AbortSignal. Рантайм им владеет, флипает его по трём причинам (разбираются в Отмена) и никогда не глотает ошибки твоего обработчика при этом.
Передавай signal в каждый async API, который его принимает:
fetch, Request, Response.body.getReader().read(), setTimeout (через AbortSignal.timeout), Worker.postMessage с AbortSignal-обёртками, большинство клиентов баз и почти все современные stream API принимают AbortSignal. Используй его везде.
Идиома signal.throwIfAborted()
Заголовок раздела «Идиома signal.throwIfAborted()»Между границами await рантайм не может прервать твой код — JavaScript однопоточный. Проверка — твоя ответственность. Самый дешёвый, идиоматичный способ — signal.throwIfAborted() сразу после каждого await:
Throw ловится рантаймом, распознаётся как abort (потому что signal.aborted == true), и прогон помечается aborted в инспекторе — не errored. Никакого console.error, никакого вызова onError в middleware. См. Поток ошибок.
Почему throw, а не if (signal.aborted) return? Throw короткозамыкает всю работу после него без необходимости тащить early-return по каждому хелперу, который ты зовёшь. С несколькими await’ами лесенка if/return становится шумной. throwIfAborted — одна строка.
Связь между возвращаемым значением обработчика и жизненным циклом прогона
Заголовок раздела «Связь между возвращаемым значением обработчика и жизненным циклом прогона»Прогон считается завершённым когда возвращённый промис обработчика устаканивается. Никакого другого сигнала “конец прогона” нет. Три следствия:
- Не делай fire-and-forget. Если ты зовёшь
fetch(...)и не await’ишь — прогон резолвится до завершения запроса; рантайм не прервёт этот орфанный запрос, когда придёт следующее событие. Ты увидишь “зомби” сетевые вызовы в Network-вкладке. - Не сохраняй промис снаружи замыкания обработчика. Что переживёт обработчик — переживёт и
signal. - Возвращаемое значение игнорируется. Обработчик возвращает
void | Promise<void>. Возврат чего-то ещё — TS-ошибка.
Конкретно, вот так — сломано:
Фикс — одна строка:
Или, если лог реально best-effort и блокироваться на нём не хочется: запусти его из отдельного триггера с concurrency: 'take-every', тогда прогон завершится мгновенно, а лог пройдёт свой жизненный цикл.
Поток ошибок
Заголовок раздела «Поток ошибок»Рантайм различает три терминальных статуса прогона, записывает их в инспектор и роутит в middleware:
| Статус | Когда | Хук middleware |
|---|---|---|
'fired' | Обработчик вернулся / зарезолвился нормально. | onActionEnd для каждого действия |
'aborted' | Обработчик реджектнулся и signal.aborted === true. | (нет — молча) |
'errored' | Обработчик реджектнулся и signal.aborted === false. | onError |
Разница между 'aborted' и 'errored' — единственное, что рантайм решает за тебя. Всё остальное — что логать, ретраить ли, как сообщать пользователю — живёт в middleware и хендлерах действий.
Последовательные await’ы с переплетёнными проверками
Заголовок раздела «Последовательные await’ы с переплетёнными проверками»Самый чистый async-обработчик читается сверху вниз: за каждым await сразу проверка abort. Никаких вложенных цепочек промисов, никаких .then.
Если пользователь разлогинился посреди загрузки, следующее событие прервёт этот прогон между двумя await’ами. Частичное состояние (profile, может быть orgs) уже окажется в твоём сторе через хендлеры действий — и это правильно. Триггер документировал, что именно коммитит каждый await.
Если хочешь, чтобы вся последовательность была атомарной (“либо всё, либо ничего”) — собирай в локальные переменные и диспатчи действия только после последнего await:
Два трейдоффа: первая версия раньше показывает частичный прогресс (выигрыш UX); вторая атомарная, но кажется медленнее. Выбирай по сценарию.
Распространённая ловушка: забыли передать signal в fetch
Заголовок раздела «Распространённая ловушка: забыли передать signal в fetch»Самый частый async-баг. Обработчик выглядит правильно, типы компилируются, happy path работает — а потом под нагрузкой пользователь видит устаревшие результаты от запроса, который должен был быть отменён три нажатия клавиш назад.
Что ты увидишь со сломанной версией под take-latest:
- 5 нажатий → 5 сетевых запросов, все в полёте.
- Рантайм прерывает первые 4 обработчика через
signal.aborted = true, ноfetch-вызовы сигнал не видят — они продолжают. - Все 5
await fetch(...)в итоге резолвятся. Первые 4 кинутAbortErrorизsignal.throwIfAborted(), если он есть. Без него все 5 зовутactions.showв порядке прибытия, не в порядке нажатий клавиш. - Пользователь видит мерцающие результаты, иногда заканчивающиеся не на том запросе.
@triggery/eslint-plugin поставляет opt-in правило (fetch-signal), которое флагает вызовы fetch() внутри async-обработчиков без { signal }.
Распространённая ловушка: ловить AbortError и продолжать
Заголовок раздела «Распространённая ловушка: ловить AbortError и продолжать»AbortError — это не ошибка в твоём сценарии, это значит, что рантайм отменил прогон. Перекинь её, чтобы рантайм классифицировал прогон как 'aborted', а не 'errored':