Каскады
Каскад — это вызов, происходящий внутри работающего обработчика. Тело useAction зовёт runtime.fire('next-event'); это событие, в свою очередь, матчится с другим триггером; обработчик того триггера запускается; и так далее. Каскады позволяют сценариям распространяться поперёк фичей так, что продьюсеру второго события не нужно знать о продьюсере первого.
Они также позволяют построить бесконечный цикл в три строки, так что рантайм поставляется с двумя ремнями безопасности по умолчанию — лимит глубины и per-fire проверка циклов — плюс хук middleware, чтобы наблюдать всё, что проходит.
Анатомия каскада
Заголовок раздела «Анатомия каскада»Ниже — цепочка, которую Triggery реально трекает. A запускает top-level событие; его обработчик зовёт действие; это действие — или сам обработчик — зовёт runtime.fire, эмитя новое событие; триггер B подписан на это событие и запускается; и так далее.
+-- depth 0 (top-level fire) --+
fire('user:signed-in') |
| |
v |
trigger A (id: 'session-bootstrap') |
| |
| actions.welcomeToast?.() --- depth 1 ----+
| \ |
| runtime.fire('toast:shown') |
| | |
| v |
| trigger B (id: 'toast-analytics')|
| | |
| | runtime.fire(...) --- depth 2 --- ...
| |
v |
(handler returns) |
+-------------------------------+Бухгалтерия рантайма — в meta:
handler({ event, meta }) {
meta.cascadeDepth; // 0 для top-level, 1 для "выпущенного изнутри родительского обработчика", …
meta.parentRunId; // run id родительского триггера (undefined на глубине 0)
meta.parentTriggerId; // trigger id родителя (undefined на глубине 0)
}Те же поля попадают в снепшоте инспектора — это и питает каскадное дерево в DevTools.
Дефолтная безопасность: maxCascadeDepth
Заголовок раздела «Дефолтная безопасность: maxCascadeDepth»createRuntime дефолтит maxCascadeDepth в 3. Всё, что за этим — пропускается; рантайм эмитит onCascade с kind: 'overflow', и диспатч этой цепочки останавливается. По умолчанию исключение не кидается.
import { createRuntime } from '@triggery/core';
const runtime = createRuntime({
maxCascadeDepth: 3, // дефолт — намеренно маленький
});Число маленькое намеренно. Три шага — этого достаточно для легитимных кейсов (“событие → обработчик → каскад-событие → обработчик → каскад-событие → обработчик”). Четыре и больше обычно значит, что кто-то прикрутил мелкий пайплайн к реестру триггеров вместо того, чтобы написать функцию. Поднимать лимит — запах, если у тебя нет письменной причины.
Как выглядит overflow
Заголовок раздела «Как выглядит overflow»Middleware onCascade происходит с полным контекстом. Без установленного middleware overflow тихий на рантайме, но всё ещё аннотирован в буфере инспектора (status 'skipped', reason cascade-overflow).
import type { Middleware } from '@triggery/core';
export const cascadeLogger: Middleware = {
name: 'cascade-logger',
onCascade({ parentTriggerId, parentRunId, newEventName, cascadeDepth, kind }) {
if (kind === 'overflow') {
console.warn(
`[cascade] depth ${cascadeDepth} exceeded — '${newEventName}' from ${parentTriggerId}/${parentRunId} dropped`,
);
}
},
};Детект циклов
Заголовок раздела «Детект циклов»Независимо от глубины, рантайм детектит циклы per top-level вызов. Пока обработчик идёт, диспетчер держит ссылку на parent-цепочку; перед повторным входом в триггер он идёт по этой цепочке, ища id триггера. Хит значит “этот триггер уже работает выше в цепочке” — второй вход пропускается с kind: 'cycle'.
fire('a') ┐
v │
trigger X │
v │ идём по цепочке: X уже здесь → cycle
actions.foo() │
v │
runtime.fire('b') │
v │
trigger Y │
v │
actions.bar() │
v │
runtime.fire('a') ┘ → onCascade({ kind: 'cycle' }), trigger X пропущенДетект циклов per-top-level-fire — независимые top-level вызовы не делят цепочку. Проверка O(depth), а не O(триггеров-в-рантайме), так что ремень безопасности не стоит ничего на горячем пути, даже с тысячами зарегистрированных триггеров.
Конфигурация поведения каскадов
Заголовок раздела «Конфигурация поведения каскадов»Сегодня одна ручка — maxCascadeDepth — плюс хук middleware onCascade. Будущие opt-in режимы ('forbid', 'throw') в роадмапе.
const runtime = createRuntime({
maxCascadeDepth: 5, // подними, если намеренно более глубокая цепочка
middleware: [cascadeLogger], // наблюдай overflow + cycle события
});Если хочешь строгого поведения сегодня — пиши middleware:
import type { Middleware } from '@triggery/core';
export const strictCascade: Middleware = {
name: 'strict-cascade',
onCascade({ kind, newEventName, parentTriggerId }) {
throw new Error(
`[cascade] ${kind} — '${newEventName}' from ${parentTriggerId} (cascade strict-mode on)`,
);
},
};Throw приземлится в try/catch родительского обработчика (по Error handling) — он не пробрасывается в runtime.fire, который by design fire-and-forget.
DEV: запретить каскады вообще
Заголовок раздела «DEV: запретить каскады вообще»В редакторе предпочитай статическую проверку из ESLint-плагина. Лимит каскадов рантайма ловит то, что просочилось в рантайм; lint-правило держит каскады вне кодовой базы.
import triggery from '@triggery/eslint-plugin';
export default [
{
plugins: { '@triggery': triggery },
rules: {
// запрещает вызовы `useEvent(...)` внутри обработчика `useAction(...)`
'@triggery/no-event-cascade': 'error',
},
},
];Это ловит самый частый каскад по статической структуре — реактор, запускающий другое событие. Рантайм держит лимит глубины сверху, потому что некоторые легитимные каскады не имеют формы useEvent (например, обработчик, зовущий runtime.fire напрямую).
Когда каскад в порядке
Заголовок раздела «Когда каскад в порядке»Каскады отрабатывают свой вес, когда каждый шаг независим и осмыслен сам по себе:
- Закрытие модалки → событие аналитики. Сценарий модалки не знает, что что-то это трекает. Сценарий аналитики не знает, какая модалка закрылась.
- Auth-refresh успешен → перевзведи watchers. Watcher’ы знают про “сессия изменилась”, не про “триггер refresh сделал изменение”.
- Документ открыт → обновление списка недавно просмотренных. Два сценария — открытие документа и учёт недавних — делят границу события.
В каждом кейсе родитель короткий, ребёнок короткий, и ни одному не нужно читать или звать другого.
export const closeModalTrigger = createTrigger<{
events: { 'modal:close-requested': { modalId: string } };
actions: { closeModal: { modalId: string } };
}>({
id: 'close-modal',
events: ['modal:close-requested'],
required: [],
handler({ event, actions }) {
actions.closeModal?.(event.payload);
runtime.fire('modal:closed', event.payload);
},
});
export const trackModalCloseTrigger = createTrigger<{
events: { 'modal:closed': { modalId: string } };
actions: { trackEvent: AnalyticsEvent };
}>({
id: 'track-modal-close',
events: ['modal:closed'],
required: [],
handler({ event, actions }) {
actions.trackEvent?.({ name: 'modal:closed', modalId: event.payload.modalId });
},
});Когда каскад — это запах
Заголовок раздела «Когда каскад — это запах»Цепочка перестаёт быть оркестрацией и начинает быть закодированной функцией в тот момент, когда один шаг существует только чтобы включить следующий.
- Три триггера, один исход.
validate → save → toast— это функция с обманчивым следом в реестре. Консолидируй (см. Борьба со спагетти). - Каскад, кормящий назад. Триггер A производит событие B; триггер B производит событие A; ты обнаруживаешь это через cycle-warning. Останови, найди третий триггер, владеющий правдой, и пусть оба зависят от него.
- Каскад через
setTimeoutилиawait. Каскад-трекинг следует только за синхронной частью async-обработчика — после первогоawaitследующийruntime.fire— это свежий top-level эмит. Если твой “каскад” работает только потому, что родитель sync, ты полагаешься на implementation detail; вытяни это в явный продьюсер.
Каскадное дерево в инспекторе
Заголовок раздела «Каскадное дерево в инспекторе»Когда инспектор включён (DEV по умолчанию, opt-in для PROD), каждый прогон несёт parentRunId / parentTriggerId в снепшоте. @triggery/devtools-redux и @triggery/devtools-bridge рендерят это как дерево:
run_3k session-bootstrap fire new-user
└─ run_3l welcome-toast fire toast:shown
└─ run_3m toast-analytics fire (no actions)Тот же вид можно собрать из runtime.getInspectorBuffer() за 30 строк в кастомной панели — см. Инспектор.
Чит-шит
Заголовок раздела «Чит-шит»| Хочешь… | Тянись к |
|---|---|
| Жёсткий потолок длины цепочки | createRuntime({ maxCascadeDepth }) |
| Наблюдать overflow’ы и циклы | Middleware.onCascade |
| Запретить каскады в редакторе | @triggery/no-event-cascade |
| Throw на каждое каскад-событие | Кастомный onCascade middleware, который re-throw’ит |
| Исследовать конкретную цепочку | meta.parentRunId + runtime.getInspectorBuffer() |