Перейти к содержимому

Владение

У триггера один слот на каждое имя условия и один слот на каждое имя действия. Когда два компонента регистрируют одно и то же имя на одном и том же триггере, только один из них может быть “тем” провайдером. Дефолтный ответ Triggery — last-write-wins: самая свежая регистрация — живая. Если она потом разрегистрируется, слот становится пустым (предыдущая регистрация не запоминается).

Это политика на одну строку с несколькими последствиями. Эта страница разбирает последствия.

Внутри каждая пара (trigger, name) имеет один слот. useCondition записывает свой геттер в слот на маунте. На размонтировании cleanup удаляет запись только если она ещё живая — устаревший токен, чья регистрация уже была перезаписана более новой записью, no-op.

register useCondition('user', () => alice)    →  слот: alice
register useCondition('user', () => bob)      →  слот: bob    (alice перезаписан, DEV warn один раз)
unregister bob                                 →  слот: ⊥     (live запись была bob, поэтому очищена)
register useCondition('user', () => charlie)  →  слот: charlie
unregister alice                               →  слот: charlie (stale-токен — no-op)

Рантайм никогда не мерджит значения. У слота один обитатель. Когда слот пуст, диспетчер ведёт себя так, будто условие никогда не регистрировалось — для required условия это значит, что обработчик пропускается с reason missing-required-condition:<name>, записанным инспектором и эмитированным через Middleware.onSkip.

Когда вторая регистрация приходит, пока первая ещё жива, рантайм эмитит одноразовое предупреждение на пару (label, triggerId, name):

[triggery] multiple condition registrations for "user" on trigger "session-bootstrap" — last write wins.
To compose values from several sources, register through a single hook.

Та же форма для действий. Предупреждение происходит один раз на пару за время жизни рантайма — re-render’ы не пере-варнят, и цикл маунта StrictMode его не триггерит (первая регистрация уже очищена к моменту монтирования второй). Если предупреждение происходит в твоём приложении, типичный фикс — один из:

  1. Выбери одного провайдера. Два смонтированных <SettingsPanel> редко интенциональны; подними состояние выше и рендери ровно один.
  2. Мердж до регистрации. Скомпонуй значение в хуке и зарегистрируй один раз.
  3. Заскоупь. Если ты реально хочешь N параллельных экземпляров, дай каждому свой <TriggerScope> — warn-once per scope, и слоты per scope.

Два свойства делают last-write-wins правильным дефолтом для UI-оркестрации.

  1. Детерминирован и восстанавливаем. Каждая пара (trigger, name) имеет одного обитателя; нет правила приоритета, зашитого в хеш-мап, и нет “first-wins, но если не выставлен priority: 'high'”. Самый свежий писатель — видимый. Тесты, hot reload и StrictMode ведут себя предсказуемо.
  2. Хорошо взаимодействует с тестами. Тест монтирует компоненты, потом зовёт rt.mockCondition(...), чтобы переопределить, что компоненты зарегистрировали — mock самая свежая запись, побеждает. Никакого флага replace: true, никакой математики приоритетов.

Альтернативные дефолты (first-wins, strict-throw, stackable-merge) ломают одно из них. Они приедут как opt-in стратегии рядом с last-write-wins; они не заменят его как дефолт. См. “Будущие стратегии” ниже.

Представь планшетное приложение с боковой панелью настроек, плюс модалку “preferences”, переиспользующую тот же <SettingsPanel> со слегка другим видом. Обе панели регистрируют условие settings на триггере уведомлений.

export const notificationTrigger = createTrigger<{
  events:     { 'new-message': Message };
  conditions: { settings: Settings };
  actions:    { showToast: ToastPayload };
}>({
  id: 'notification-on-message',
  events: ['new-message'],
  required: ['settings'],
  handler({ event, conditions, actions }) {
    if (!conditions.settings.notifications) return;
    actions.showToast?.({ title: event.payload.author, body: event.payload.text });
  },
});
export function SettingsPanel({ settings }: { settings: Settings }) {
  useCondition(notificationTrigger, 'settings', () => settings, [settings]);
  // …UI редактора…
  return null;
}
<>
  <SettingsPanel settings={sidebarSettings} />        {/* монтируется первой, пишет свой геттер */}
  {modalOpen && (
    <SettingsPanel settings={modalSettings} />        {/* перезаписывает слот пока открыта */}
  )}
</>

Пока модалка открыта, триггер уведомлений читает modalSettings. Когда модалка закрывается, её cleanup удаляет слот — и поскольку v0.10 не стэкает, геттер сайдбара не восстанавливается автоматически: слот остаётся пустым, пока сайдбар не отрендерится снова и не запишет повторно. (React зовёт effect при каждом ререндере с изменёнными deps, так что смена настроек снова запустит effect, но пассивный re-mount сайдбара — нет.) Для гарантированного канонического владельца подними проводку выше:

export function SettingsProvider() {
  const settings = useMergedSettings(sidebarSettings, modalSettings);    // твоё правило мерджа
  useCondition(notificationTrigger, 'settings', () => settings, [settings]);
  return null;
}

Теперь только одна регистрация. Два <SettingsPanel> становятся чистым UI; владение — у одного компонента.

Поскольку заглушки — это просто регистрации в рантайме, тест, зовущий rt.mockCondition(...) после регистраций, отрендеренных компонентами, получает last-write-wins бесплатно. Нет API mockOverride — есть просто один слот, и вызов теста — самая свежая запись.

import { createTrigger } from '@triggery/core';
import { createTestRuntime } from '@triggery/testing';
import { render } from '@testing-library/react';

test('notification fires when settings are on', async () => {
  const rt = createTestRuntime();
  // …рендерим компоненты, регистрирующие реальный геттер `settings`…
  render(
    <TriggerRuntimeProvider runtime={rt}>
      <SettingsPanel settings={{ notifications: true }} />
    </TriggerRuntimeProvider>,
  );

  // Переопределяем геттер компонента взглядом теста на мир.
  const toast = vi.fn();
  rt.mockAction(notificationTrigger, 'showToast', toast);
  rt.mockCondition(notificationTrigger, 'settings', { notifications: true });

  rt.fireSync('new-message', { author: 'a', text: 'b', channelId: 'c' });
  expect(toast).toHaveBeenCalled();
});

Две вещи важны:

  1. mockCondition/mockAction зовутся после render(...). Компоненты уже записали свои геттеры; тест записывает новое значение в слот.
  2. rt.fireSync запускает диспетчер синхронно — тесту не нужно await flushMicrotasks() между mockCondition и ассертом.

Порядок важен; если подменишь первым и отрендеришь вторым, геттер компонента — самая свежая запись и перезаписывает подмену. Тест ещё проходит, когда компонент случайно регистрирует то же значение, и тонко ломается, когда нет. Считай “подмены идут после render” жёстким правилом. См. Модульные тесты.

Скоупы меняют в какой слот идёт регистрация; они не меняют политику внутри слота. Внутри одного скоупа — last-write-wins. Между скоупами слоты независимы.

<TriggerScope id="chat-panel">
  <ChatPanel id="general" />   {/* регистрирует useCondition('activeChannelId', …) в 'chat-panel' */}
  <ChatPanel id="random" />    {/* вторая регистрация в 'chat-panel' — коллизия, warn-once */}
</TriggerScope>

<TriggerScope id="chat-panel">
  <ChatPanel id="hiring" />    {/* всё ещё скоуп 'chat-panel' — тот же слот продолжается, warn уже был */}
</TriggerScope>

Если три панели реально независимые экземпляры, каждая должна быть обёрнута в свой id скоупаchat-panel:general, chat-panel:random, chat-panel:hiring. scope: 'chat-panel' триггера — одно объявление; id скоупа на React-стороне — то, что нарезает слоты.

См. Скоупы для полной истории; владение и скоупы компонуются ортогонально.

Две вещи могут очистить регистрацию:

  • Компонент, который её зарегистрировал, размонтируется. Стандартный 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 предсказуемым, а не “умным”.

v1.0 поставляется с одной стратегией: last-write-wins. В роадмапе три opt-in альтернативы. Они здесь набросаны, чтобы ты мог читать API, когда оно приедет.

Регистрация предоставляет значение или partial; рантайм мерджит все живые регистрации user-provided комбайнером.

useCondition(trigger, 'flags', () => ({ beta: true }), [], {
  strategy: 'stackable',
  combine: (a, b) => ({ ...a, ...b }),
});

Use case: feature-флаги, собранные из нескольких источников, telemetry-теги, аккумулированные из feature-уровневых провайдеров, и т.п. Сегодня правильная форма — “мердж до регистрации”; см. пример SettingsProvider выше.

Первая регистрация на паре (trigger, name) остаётся; последующие регистрации — no-op (всё ещё с DEV-варном).

useCondition(trigger, 'flags', () => flags, [flags], { strategy: 'first-wins' });

Use case: app shell, который хочет, чтобы его провайдер был каноничным, даже когда суб-фичи пытаются переопределить.

Вторая регистрация бросает синхронно. Полезно в тестах, где два смонтированных провайдера одновременно — всегда баг.

useCondition(trigger, 'flags', () => flags, [flags], { strategy: 'strict' });

Сегодня приближай это в тестах кастомной Middleware.onSkip-проверкой или ассерти, что DEV-варн не был эмитирован.

Антипаттерн: полагаться на порядок регистрации

Заголовок раздела «Антипаттерн: полагаться на порядок регистрации»
<ContextA><SettingsPanel /></ContextA>
<ContextB><SettingsPanel /></ContextB>
{/* что окажется "позже" — побеждает, но это порядок рендера, а не визуальный порядок */}

Если правильный ответ зависит от порядка рендера — это хрупко. Подними решение вверх и выбери одного провайдера. Исход по порядку рендера детерминирован для данного дерева, но это не то, что большинство читателей угадают по JSX.

Антипаттерн: re-mounting для “освежить” значение

Заголовок раздела «Антипаттерн: re-mounting для “освежить” значение»
{key && <SettingsPanel key={key} settings={fresh} />}

useCondition уже читает через свой геттер при каждом вызове — кеша нет. Re-mounting для рефреша — no-op для семантики владения (новый mount становится верхом, но значение и так бы обновилось, потому что геттер замыкает свежее состояние через deps). Если ты пере-key’ишь для этой причины, массив deps геттера, скорее всего, неверный.

Warn-once — это одна строка в консоли. Если ты “фиксишь” это, останавливая второе монтирование if’ом — это правильный фикс. Если “фиксишь”, добавив console.warn = noop в test setup — варн вернётся для следующей коллизии, и ты потеряешь сигнал, что эта коллизия существует.

  • Если видишь DEV-варн multiple ... registrations, обе регистрации должны быть живы одновременно — или одна из них забытый mount?
  • Для тестов: тест зовёт rt.mockCondition / rt.mockAction после render(...)? Если зовёт до — геттер компонента наверху, и подмена затенён.
  • Для мульти-инстанс UI: каждый инстанс обёрнут в свой <TriggerScope>, чтобы слоты не коллидили?
  • Для required условий app-shell: ровно один компонент отвечает за их регистрацию?