Владение
У триггера один слот на каждое имя условия и один слот на каждое имя действия. Когда два компонента регистрируют одно и то же имя на одном и том же триггере, только один из них может быть “тем” провайдером. Дефолтный ответ Triggery — last-write-wins: самая свежая регистрация — живая. Если она потом разрегистрируется, слот становится пустым (предыдущая регистрация не запоминается).
Это политика на одну строку с несколькими последствиями. Эта страница разбирает последствия.
Дефолт: last-write-wins
Заголовок раздела «Дефолт: last-write-wins»Внутри каждая пара (trigger, name) имеет один слот. useCondition записывает свой геттер в слот на маунте. На размонтировании cleanup удаляет запись только если она ещё живая — устаревший токен, чья регистрация уже была перезаписана более новой записью, no-op.
Рантайм никогда не мерджит значения. У слота один обитатель. Когда слот пуст, диспетчер ведёт себя так, будто условие никогда не регистрировалось — для required условия это значит, что обработчик пропускается с reason missing-required-condition:<name>, записанным инспектором и эмитированным через Middleware.onSkip.
DEV warn-once на коллизии
Заголовок раздела «DEV warn-once на коллизии»Когда вторая регистрация приходит, пока первая ещё жива, рантайм эмитит одноразовое предупреждение на пару (label, triggerId, name):
Та же форма для действий. Предупреждение происходит один раз на пару за время жизни рантайма — re-render’ы не пере-варнят, и цикл маунта StrictMode его не триггерит (первая регистрация уже очищена к моменту монтирования второй). Если предупреждение происходит в твоём приложении, типичный фикс — один из:
- Выбери одного провайдера. Два смонтированных
<SettingsPanel>редко интенциональны; подними состояние выше и рендери ровно один. - Мердж до регистрации. Скомпонуй значение в хуке и зарегистрируй один раз.
- Заскоупь. Если ты реально хочешь N параллельных экземпляров, дай каждому свой
<TriggerScope>— warn-once per scope, и слоты per scope.
Почему такой дефолт
Заголовок раздела «Почему такой дефолт»Два свойства делают last-write-wins правильным дефолтом для UI-оркестрации.
- Детерминирован и восстанавливаем. Каждая пара
(trigger, name)имеет одного обитателя; нет правила приоритета, зашитого в хеш-мап, и нет “first-wins, но если не выставленpriority: 'high'”. Самый свежий писатель — видимый. Тесты, hot reload и StrictMode ведут себя предсказуемо. - Хорошо взаимодействует с тестами. Тест монтирует компоненты, потом зовёт
rt.mockCondition(...), чтобы переопределить, что компоненты зарегистрировали — mock самая свежая запись, побеждает. Никакого флагаreplace: true, никакой математики приоритетов.
Альтернативные дефолты (first-wins, strict-throw, stackable-merge) ломают одно из них. Они приедут как opt-in стратегии рядом с last-write-wins; они не заменят его как дефолт. См. “Будущие стратегии” ниже.
Разбор примера: две SettingsPanel
Заголовок раздела «Разбор примера: две SettingsPanel»Представь планшетное приложение с боковой панелью настроек, плюс модалку “preferences”, переиспользующую тот же <SettingsPanel> со слегка другим видом. Обе панели регистрируют условие settings на триггере уведомлений.
Пока модалка открыта, триггер уведомлений читает modalSettings. Когда модалка закрывается, её cleanup удаляет слот — и поскольку v0.10 не стэкает, геттер сайдбара не восстанавливается автоматически: слот остаётся пустым, пока сайдбар не отрендерится снова и не запишет повторно. (React зовёт effect при каждом ререндере с изменёнными deps, так что смена настроек снова запустит effect, но пассивный re-mount сайдбара — нет.) Для гарантированного канонического владельца подними проводку выше:
Теперь только одна регистрация. Два <SettingsPanel> становятся чистым UI; владение — у одного компонента.
Как этим пользуются тесты
Заголовок раздела «Как этим пользуются тесты»Поскольку заглушки — это просто регистрации в рантайме, тест, зовущий rt.mockCondition(...) после регистраций, отрендеренных компонентами, получает last-write-wins бесплатно. Нет API mockOverride — есть просто один слот, и вызов теста — самая свежая запись.
Две вещи важны:
mockCondition/mockActionзовутся послеrender(...). Компоненты уже записали свои геттеры; тест записывает новое значение в слот.rt.fireSyncзапускает диспетчер синхронно — тесту не нужноawait flushMicrotasks()междуmockConditionи ассертом.
Порядок важен; если подменишь первым и отрендеришь вторым, геттер компонента — самая свежая запись и перезаписывает подмену. Тест ещё проходит, когда компонент случайно регистрирует то же значение, и тонко ломается, когда нет. Считай “подмены идут после render” жёстким правилом. См. Модульные тесты.
Владение и скоупы
Заголовок раздела «Владение и скоупы»Скоупы меняют в какой слот идёт регистрация; они не меняют политику внутри слота. Внутри одного скоупа — last-write-wins. Между скоупами слоты независимы.
Если три панели реально независимые экземпляры, каждая должна быть обёрнута в свой id скоупа — chat-panel:general, chat-panel:random, chat-panel:hiring. scope: 'chat-panel' триггера — одно объявление; id скоупа на React-стороне — то, что нарезает слоты.
См. Скоупы для полной истории; владение и скоупы компонуются ортогонально.
Семантика disposal
Заголовок раздела «Семантика disposal»Две вещи могут очистить регистрацию:
- Компонент, который её зарегистрировал, размонтируется. Стандартный React effect cleanup; запускается
RegistrationToken.unregister()рантайма. Если слот всё ещё занят этой регистрацией, слот очищается; если более поздняя запись уже перезаписала его, unregister — silent no-op (так stale cleanup никогда не сотрёт свежую регистрацию). - Триггер пере-регистрируется с тем же id. Last-mount-wins применяется и к триггерам — re-registration триггера дропает каждый прогон в полёте, отменяет каждый таймер, и новый триггер стартует с пустыми condition/action слотами. Это в основном кейс hot-reload’а; в проде ты редко зовёшь
createTriggerболее одного раза на тот же id.
Рантайм не отвечает за очистку условий / действий, когда их владеющий триггер заменён — замена стартует пустой, и компоненты пере-регистрируются на своём цикле эффектов. Если компонент пропустит свой effect-цикл целиком (необычный hot-reload edge), его регистрация пропадёт. Это намеренно: делает hot reload предсказуемым, а не “умным”.
Будущие стратегии (opt-in, post-V1)
Заголовок раздела «Будущие стратегии (opt-in, post-V1)»v1.0 поставляется с одной стратегией: last-write-wins. В роадмапе три opt-in альтернативы. Они здесь набросаны, чтобы ты мог читать API, когда оно приедет.
stackable
Заголовок раздела «stackable»Регистрация предоставляет значение или partial; рантайм мерджит все живые регистрации user-provided комбайнером.
Use case: feature-флаги, собранные из нескольких источников, telemetry-теги, аккумулированные из feature-уровневых провайдеров, и т.п. Сегодня правильная форма — “мердж до регистрации”; см. пример SettingsProvider выше.
first-wins
Заголовок раздела «first-wins»Первая регистрация на паре (trigger, name) остаётся; последующие регистрации — no-op (всё ещё с DEV-варном).
Use case: app shell, который хочет, чтобы его провайдер был каноничным, даже когда суб-фичи пытаются переопределить.
Вторая регистрация бросает синхронно. Полезно в тестах, где два смонтированных провайдера одновременно — всегда баг.
Сегодня приближай это в тестах кастомной Middleware.onSkip-проверкой или ассерти, что DEV-варн не был эмитирован.
Антипаттерны
Заголовок раздела «Антипаттерны»Антипаттерн: полагаться на порядок регистрации
Заголовок раздела «Антипаттерн: полагаться на порядок регистрации»Если правильный ответ зависит от порядка рендера — это хрупко. Подними решение вверх и выбери одного провайдера. Исход по порядку рендера детерминирован для данного дерева, но это не то, что большинство читателей угадают по JSX.
Антипаттерн: re-mounting для “освежить” значение
Заголовок раздела «Антипаттерн: re-mounting для “освежить” значение»useCondition уже читает через свой геттер при каждом вызове — кеша нет. Re-mounting для рефреша — no-op для семантики владения (новый mount становится верхом, но значение и так бы обновилось, потому что геттер замыкает свежее состояние через deps). Если ты пере-key’ишь для этой причины, массив deps геттера, скорее всего, неверный.
Антипаттерн: глушить DEV-варн
Заголовок раздела «Антипаттерн: глушить DEV-варн»Warn-once — это одна строка в консоли. Если ты “фиксишь” это, останавливая второе монтирование if’ом — это правильный фикс. Если “фиксишь”, добавив console.warn = noop в test setup — варн вернётся для следующей коллизии, и ты потеряешь сигнал, что эта коллизия существует.
Чек-лист ревьюера
Заголовок раздела «Чек-лист ревьюера»- Если видишь DEV-варн
multiple ... registrations, обе регистрации должны быть живы одновременно — или одна из них забытый mount? - Для тестов: тест зовёт
rt.mockCondition/rt.mockActionпослеrender(...)? Если зовёт до — геттер компонента наверху, и подмена затенён. - Для мульти-инстанс UI: каждый инстанс обёрнут в свой
<TriggerScope>, чтобы слоты не коллидили? - Для
requiredусловий app-shell: ровно один компонент отвечает за их регистрацию?