StrictMode (React)
<StrictMode> у React существует, чтобы сделать целый класс багов невозможным игнорировать. В dev он намеренно монтирует каждый компонент, тут же размонтирует и снова монтирует — так что любое состояние, не убранное при unmount, становится видно немедленно, а не после навигации, переключения вкладок или hot-reload. Те же правила распространяются на useEffect: каждый эффект запускается, чистится, потом запускается снова.
Если твой useEffect регистрирует подписку, которая не убирается в возвращённой функции, StrictMode сразу показывает дубликат регистрации. В этом и смысл. Обратная сторона: каждой библиотеке, чьи хуки что-нибудь регистрируют, нужно пережить цикл mount-unmount-mount без утечек, двойных срабатываний или потерянных обработчиков.
Triggery — переживает. Эта страница — объяснение почему.
Что StrictMode на самом деле делает
Заголовок раздела «Что StrictMode на самом деле делает»В dev жизненный цикл рендера React выглядит так:
- Смонтировать компонент.
- Запустить эффекты.
- Запустить cleanup-функцию каждого эффекта.
- Запустить эффекты снова.
- (Дальше идёт пользовательское взаимодействие.)
В проде StrictMode — no-op, дерево монтируется один раз. Так что если твой код корректен только при условии «эффекты запускаются ровно один раз», баг ты найдёшь в проде, через недели.
Поэтому документация React явно рекомендует StrictMode для новых приложений, а сниппет в Getting started у Triggery оборачивает корень именно в него.
Почему Triggery безопасен
Заголовок раздела «Почему Triggery безопасен»Хуки Triggery регистрируют токен в useEffect и снимают регистрацию в cleanup. Полный цикл StrictMode выглядит так:
После шага 3 активна ровно одна регистрация. Первая (token A) полностью убрана до того, как её место занимает вторая (token B). Никаких двойных срабатываний триггера; никаких дубликатов условия; значение, которое видит триггер, однозначно.
Механика — один слот на пару (trigger, name): рантайм держит плоский Map<name, fn>. Регистрация перезаписывает слот. Cleanup удаляет запись только если она ещё live (stale-токен — no-op). «Активное» значение — это то, что в слоте.
Когда в момент срабатывания рантайм читает conditions.settings, он берёт значение слота. Когда обработчик зовёт actions.showToast?.(), берётся слот действий. Last-write-wins — согласовано, детерминированно, безопасно при StrictMode.
Семантика refcount + cleanup
Заголовок раздела «Семантика refcount + cleanup»Ключевой инвариант: на каждый вызов register* приходится ровно один unregister, и рантайм никогда не путает cleanup одного монтирования с регистрацией другого, даже если их токены разные.
Вот тот же React-хук, с комментариями:
Важны три свойства:
- Токены уникальны на каждую регистрацию. Каждый вызов
useConditionотдаёт собственный токен; cleanup сравнивает identity функции с тем, что в слоте. unregisterидемпотентен и stale-safe. Вызов дважды — no-op. Если регистрация уже была перезаписана более поздней — cleanup не сотрёт свежую запись.- Слот всегда отражает последнюю запись. Источник истины —
Map<name, fn>. Когда новая регистрация перезаписывает слот, диспетчер сразу видит новое значение.
Это и делает так, что цикл «mount → cleanup → mount» выглядит, будто отработал только второй mount.
Никаких дубликатов регистраций обработчиков
Заголовок раздела «Никаких дубликатов регистраций обработчиков»Самый частый баг «библиотека ломается под StrictMode»: обработчик, подписавшийся в mount #1, остаётся подписанным, а mount #2 добавляет ещё одну подписку. Теперь события вызывают двух обработчиков.
В Triggery такого не бывает, потому что:
- Cleanup-функция из
useEffectзапускается перед вторым mount. - Cleanup зовёт
token.unregister(), который удаляет ровно ту запись в стеке, которую добавил mount #1. - К моменту второго mount стек пуст, и mount #2 пушит свою регистрацию.
Если твой обработчик useAction логирует каждый вызов, увидишь ровно один лог на срабатывание, каждый раз. То же верно для useEvent (он даже не регистрирует — просто возвращает стабильный коллбэк) и для useInspectHistory (подписывается на инспектор и отписывается в cleanup).
В библиотеке, не справляющейся со StrictMode, этот тест падает с двойным вызовом onPing. У Triggery — проходит.
Несколько живых регистраций: когда важен last-write-wins
Заголовок раздела «Несколько живых регистраций: когда важен last-write-wins»Цикл mount-unmount-mount у StrictMode — один случай. Другой случай — реально разные два компонента, оба регистрирующие одно и то же условие или действие:
Рантайм один раз ругается в DEV («multiple condition registrations for ‘settings’ on trigger ‘message-received’ — last write wins») и использует последний геттер. Когда <SettingsPanelB /> размонтируется, слот очищается — геттер <SettingsPanelA /> не восстанавливается автоматически (v0.10 убрала stack-based fallback). Если паттерн «слоистый override» нужен в приложении, подними проводку выше так, чтобы один компонент владел слотом. См. Владение для паттернов.
Solid: двойной вызов owner’а в dev
Заголовок раздела «Solid: двойной вызов owner’а в dev»Owner-graph-модель Solid означает, что компоненты монтируются один раз, а реактивность отслеживается через сигналы — по умолчанию аналога «двойного вызова эффектов» из React тут нет. Однако:
- HMR перезапускает setup-функции; срабатывают коллбэки
onCleanup, потом setup перезапускается. - Некоторые плагины Solid (например,
solid-devtools) инструментируют owner-граф и могут перезапустить setup в dev.
Биндинги Triggery для Solid зовут runtime.registerCondition(...) прямо в setup, а onCleanup(() => token.unregister()) — после. Применяется тот же цикл «register → cleanup → register» — last-write-wins на слоте, никаких дубликатов регистраций.
Если ты пишешь Solid-компонент, в котором setup перезапускается (HMR или иначе), сначала отрабатывает cleanup, потом новый setup пушит свежую регистрацию. Инвариант тот же.
Vue: отслеживание реактивности в dev
Заголовок раздела «Vue: отслеживание реактивности в dev»В Vue 3 setup() запускается один раз на инстанс компонента — никакого двойного вызова в dev. Биндинги Triggery используют onScopeDispose(() => token.unregister()), чтобы привязать время жизни регистрации к компоненту (или к явному effectScope). Когда effect-скоуп удаляется, токен снимается; если ты заново монтируешь компонент, начинается новый скоуп и пушится свежая регистрация.
Hot-module-reload в Vue по умолчанию подменяет определение компонента без удаления активного скоупа — поэтому регистрации Triggery переживают HMR. Это полезное свойство (история инспектора сохраняется между правками), и его нет у React.
Гоняй набор тестов с включённым StrictMode
Заголовок раздела «Гоняй набор тестов с включённым StrictMode»Оборачивание тестового приложения в <StrictMode> — бесплатная страховка. По цене это почти даром — вторая пара mount/cleanup проходит за микросекунды — а ловит реальный класс багов в момент их появления.
Если тест начинает падать только под StrictMode — баг в пути cleanup, и тестируемая библиотека — одна из двух: либо сам тестируемый компонент, либо то, на что он подписан. Сам Triggery безопасен для StrictMode — если тест падает здесь, компонент течёт не-Triggery-подпиской.
HMR и реестр триггеров
Заголовок раздела «HMR и реестр триггеров»Связанный вопрос о жизненном цикле: что происходит, когда @triggery/vite делает hot-replace файла *.trigger.ts?
Автообнаружение перезапускает createTrigger(config) на новом модуле. registerTrigger у рантайма идемпотентен по id — найдя существующий триггер с тем же id, он сносит старую регистрацию (отменяет in-flight-обработчики, чистит таймеры, де-индексирует имена событий) и подменяет конфиг новым. История инспектора сохраняется (она привязана к runId, а не к идентичности объекта триггера).
Стеки condition/action не очищаются при замене триггера — ими владеют компоненты, а не триггер. Если id триггера остаётся прежним, существующие стеки продолжают применяться; новый обработчик работает против тех же зарегистрированных провайдеров и реакторов. Именно это делает HMR таким бодрым: правишь триггер, сохраняешь, следующее срабатывание запускает новую логику без перемонтирования компонентов.
См. Автообнаружение — там полная история HMR.
| Беспокойство | Поведение Triggery |
|---|---|
| React StrictMode mount → cleanup → mount | Между mount-ами отрабатывает cleanup, слот пуст, второй mount записывает одну регистрацию. Одна живая регистрация. |
| Два провайдера для одного условия | Last-write-wins: второй провайдер перезаписывает слот, при unmount слот очищается (а не откатывается). DEV-warning один раз. |
| Асинхронные обработчики в полёте во время cleanup | signal.aborted переключается в true; обработчик может коротко завершиться. Новый mount стартует с чистого листа. |
| HMR файла триггера | Триггер заменяется атомарно; existing condition/action слоты очищаются; история инспектора сохраняется. |
Solid onCleanup / Vue onScopeDispose | Та же семантика слота — last-write-wins, никаких дубликатов. |
Принцип во всех трёх биндингах один: регистрируй токен на пути mount, снимай его на пути cleanup, никогда не предполагай, что эффекты запускаются ровно один раз. Это и делает рантайм безопасным по построению.