Отмена
Каждый async-обработчик получает AbortSignal как ctx.signal. Эта страница покрывает, откуда берётся сигнал, идиоматичные паттерны очистки и историю интеропа для кода, которому нужен собственный AbortController.
Если ты ещё не читал Асинхронные обработчики и Стратегии параллелизма — начни оттуда, там покрыты основы signal.throwIfAborted() и поведение abort на каждой стратегии.
Три источника abort’а
Заголовок раздела «Три источника abort’а»Triggery флипает signal.aborted в true по одной из трёх причин. У каждой — стабильная строка в signal.reason, которую можно так же прочитать из инспектора.
| Источник | Когда происходит | signal.reason |
|---|---|---|
take-latest supersedes | Более новое событие для того же триггера происходит, пока прогон ещё в полёте, и триггер использует concurrency: 'take-latest'. | 'superseded-by-latest' |
| Рантайм / скоуп диспознут | Вызван runtime.dispose(), родительский <TriggerScope> размонтируется или триггер удалён через trigger.dispose(). | 'disposed' |
| HMR-релоад | Vite / webpack HMR заменил модуль, который определил триггер. Старый экземпляр диспозится перед бутом нового. | 'hmr' |
Это полный список. Источника “пользователь нажал cancel” нет — если нужна user-driven отмена, выстави её вызовом follow-up события с take-latest или подключи собственный AbortController (см. Интероп).
Инспектор записывает причину
Заголовок раздела «Инспектор записывает причину»Каждый abort попадает в кольцевой буфер инспектора со status: 'aborted' и строкой reason. Буфер включён в DEV по умолчанию; можно включить в PROD через createRuntime({ inspector: true }).
В React useInspectHistory(trigger) из @triggery/react даёт живой список. DevTools-мост (@triggery/devtools-bridge) сериализует те же снепшоты через postMessage в Redux DevTools-совместимое расширение.
Ручная работа с signal в долгих операциях
Заголовок раздела «Ручная работа с signal в долгих операциях»Для всего, что занимает больше одного I/O-вызова — чанкованные загрузки, paginated API, чтения файлов, round-trip’ы воркеров — проверяй signal на каждой границе. Рантайм не может прервать твой код между await’ами; это всё ещё на тебе.
Два паттерна, на которые стоит обратить внимание:
- Reader использует
try { … } finally { reader.releaseLock(); }.finallyотработает независимо от того, как закончится цикл — чисто, throw’ом (включаяAbortErrorизthrowIfAborted) или прерыванием внешним abort’ом. Очистка ресурсов живёт вfinally, а не вsignal.addEventListener('abort'), потому что последний не сработает, если abort случился до прикрепления листенера. actions.throttle(100).reportProgress?.(...)делает per-chunk вызовы действий дешёвыми. Throttled-таймер отменяется вместе с триггером, если рантайм диспозится.
Паттерн signal.addEventListener('abort', cleanup)
Заголовок раздела «Паттерн signal.addEventListener('abort', cleanup)»Когда ты владеешь ресурсом, который не AbortSignal-aware (клиент базы, открытый WebSocket, setInterval), прикрепи one-shot abort-листенер:
Три вещи на заметить:
{ once: true }обязателен — без него ты течёшь листенером на каждый нормально завершившийся прогон.- Парный
removeEventListenerзапускается вfinally, покрывая и happy path, и abort. - Очистка идемпотентна: дважды вызвать
ws.close()— безвредно. Cleanup-обратные вызовы всегда должны быть идемпотентными, потому что рантайм может вызвать abort, твойfinallyможет запуститься, и любой явный ретрай в catch-пути тоже — всё это может произойти.
Чек-лист очистки ресурсов
Заголовок раздела «Чек-лист очистки ресурсов»Используй этот список при ревью async-обработчика, владеющего ресурсами:
- Каждый
fetch(...)получает{ signal }. - Каждый
setTimeout / setIntervalлибо принимает signal (AbortSignal.timeout), либо чистится вfinally. - Каждый
addEventListenerлибо передаёт{ signal }, либо парныйremoveEventListenerвfinally. - Каждый reader / writer (
ReadableStream, file handle, DB-транзакция) закрывается / освобождается вfinally. - Каждый
EventSource,WebSocket,BroadcastChannel, созданный в обработчике, закрывается. - Ты не сохраняешь ссылку на
signalпрогона или его abort-листенеры снаружи замыкания обработчика.
Если всё из списка соблюдено, единственный способ потечь — написать fetch(url) без { signal }. ESLint-правило fetch-signal из @triggery/eslint-plugin флагает ровно этот случай.
AbortController интероп
Заголовок раздела «AbortController интероп»Можешь захотеть связать свой AbortController к ctx.signal — обычно по одной из трёх причин:
- Per-request таймаут поверх жизни триггера.
- User-driven cancel кнопка.
- Fan-out, где ты стартуешь несколько параллельных запросов и хочешь, чтобы один из них можно было отменять независимо.
Стандартный браузерный API для “abort, если abort’ится любой из сигналов” — AbortSignal.any.
Если триггер superseded → signal.aborted true, throwIfAborted кидает, прогон классифицируется как 'aborted'. Если таймаут сработал первым → signal.aborted false, fetch реджектится AbortError от таймаута, catch-путь может бранчить по composite.reason, если ты его сохранил.
Здесь conditions.userCancel — обёртка { signal: AbortSignal }, выставленная твоим компонентом cancel-кнопки как условие. Сигнал триггера и сигнал пользователя мерджатся для вызова fetch, а catch-путь разруливает.
У каждого запроса свой контроллер; сигнал триггера пробрасывается во все через один abort-листенер. Можно позвать ctrls[2].abort() посреди обработчика, чтобы отменить только один запрос, не задевая остаток прогона.
Распространённая ловушка: race conditions в записях после abort’а
Заголовок раздела «Распространённая ловушка: race conditions в записях после abort’а»Рантайм abort’ит функцию обработчика. Он не откатывает работу, которую обработчик уже задиспатчил. Что попало в твой стор до точки abort’а — там и осталось.
Если прогон supersede’ится между setData и markReady, UI показывает данные, но флаг “ready” всё ещё false. Два частых фикса:
-
Помечай записи run id (
meta.runId). Реакторы выкидывают записи, чей run id не совпадает с последним для этого триггера. -
Собирай локально, диспатчи в конце. Платишь latency, получаешь атомарные переходы:
“Правильного” ответа нет — трейдофф: инкрементальный UX vs атомарная семантика. Неправильно — оставлять разрыв без внимания.
Распространённая ловушка: пропущенный throwIfAborted
Заголовок раздела «Распространённая ловушка: пропущенный throwIfAborted»Что происходит под take-latest:
- Прогон A стартует
fetch. Прогон B происходит, abort’итsignalA, стартует свой fetch. fetchA реджектитсяAbortError. Но твой обработчик этого не видит —.then(r => r.json())на ветке resolved.- Стоп —
fetchреджектится, значит цепочка реджектится. Да. Значит обработчик реджектится сAbortError, рантайм видит, чтоsignal.abortedtrue, классифицирует как'aborted'. Чище, чем кажется.
А вот реально сломанный вариант:
Catch-all глотает AbortError от abort’а, и теперь actions.show?.({ items: [] }) затирает то, что записал более новый прогон. Всегда проверяй signal.aborted в catch-блоках:
Распространённая ловушка: проглоченный AbortError портит статус прогона
Заголовок раздела «Распространённая ловушка: проглоченный AbortError портит статус прогона»AbortError — это настоящий Error (технически — DOMException с name === 'AbortError'). Если ты залогаешь его как обычный failure, твой error-репортёр решит, что прогон упал — хотя он чисто завершился.
Та же логика применима везде, где есть try/catch: aborts re-throw’аются, реальные ошибки обрабатываются.
Распространённая ловушка: закешировал signal для будущего прогона
Заголовок раздела «Распространённая ловушка: закешировал signal для будущего прогона»ctx.signal принадлежит этому прогону и инвалидируется, когда прогон заканчивается. Сохранять его в module-scope переменной, замыкании, переживающем обработчик, или возвращаемом промисе — даёт неверное поведение и может течь.
Если нужна кросс-прогонная отмена, выстави cancel-механизм условием (внешний AbortController, сигнал которого ты читаешь через useCondition) и мерджи его с per-run сигналом через AbortSignal.any внутри обработчика. Не пытайся протащить ctx.signal наружу его прогона.