Ownership
A trigger has one slot for each condition name and one slot for each action name. When two components register the same name on the same trigger, only one of them can be “the” provider. Triggery’s default answer is last-write-wins: the most recent registration is the live one. If it later unregisters, the slot becomes empty (the previous registration is not remembered).
It is a one-line policy with several consequences. This page walks through the consequences.
The default: last-write-wins
Section titled “The default: last-write-wins”Internally each (trigger, name) pair has one slot. useCondition writes its getter into the slot on mount. On unmount, the cleanup removes the entry only if it’s still the live one — a stale token whose registration was already overwritten by a newer write is a no-op.
The runtime never merges values. The slot has one occupant. When the slot is empty, the dispatcher behaves as if the condition was never registered — for a required condition that means the handler is skipped with reason missing-required-condition:<name>, recorded by the inspector and emitted via Middleware.onSkip.
DEV warn-once on collision
Section titled “DEV warn-once on collision”When a second registration arrives while the first is still alive, the runtime emits a one-time warning per (label, triggerId, name):
The same shape applies to actions. The warning fires once per pair for the lifetime of the runtime — re-renders don’t re-warn, and StrictMode’s mount cycle does not trigger it (the first registration has already been cleared by the time the second one mounts). If the warning fires in your app, the typical fix is one of:
- Pick one provider. Two
<SettingsPanel>s mounted at once is rarely intentional; lift the state up and render exactly one. - Merge before registering. Compose the value in a hook and register once.
- Scope it. If you really do want N parallel instances, give each its own
<TriggerScope>— the warn-once is per scope, and the slots are per scope.
Why this default
Section titled “Why this default”Two properties make last-write-wins the right default for UI orchestration.
- It is deterministic and recoverable. Each
(trigger, name)pair has one occupant; there is no precedence rule baked into a hash map and no “first-wins, but unless you setpriority: 'high'”. The latest writer is the visible one. Tests, hot reload, and StrictMode all behave predictably. - It interacts well with tests. A test mounts components, then calls
rt.mockCondition(...)to override what the components registered — the mock is the most recent write and wins. Noreplace: trueflag, no precedence math.
The alternative defaults (first-wins, strict-throw, stackable-merge) each break one of these. They will land as opt-in strategies alongside last-write-wins; they will not replace it as the default. See “Future strategies” below.
A worked example: two SettingsPanels
Section titled “A worked example: two SettingsPanels”Imagine a tablet app with a sidebar of settings, plus a “preferences” modal that re-uses the same <SettingsPanel> with a slightly different view. Both panels register a settings condition on the notification trigger.
While the modal is open, the notification trigger reads modalSettings. When the modal closes, its cleanup deletes the slot — and because v0.10 does not stack, the sidebar’s getter is not automatically restored: the slot ends up empty until the sidebar re-renders and writes again. (React calls the effect on every render where its deps changed, so a settings change re-runs the effect, but a passive re-mount of the sidebar may not.) For a guaranteed canonical owner, lift the wiring up:
Now there is only one registration. The two <SettingsPanel>s become pure UI; ownership is owned by a single component.
How tests use this
Section titled “How tests use this”Because mocks are just registrations on a runtime, a test that calls rt.mockCondition(...) after the component-rendered registrations gets last-write-wins for free. There is no mockOverride API — there is just one slot, and the test’s call is the latest write.
Two pieces matter:
mockCondition/mockActionare called afterrender(...). The components have already written their getters; the test writes a new value into the slot.rt.fireSyncruns the dispatcher synchronously — the test does not needawait flushMicrotasks()betweenmockConditionand the assertion.
Order matters; if you mock first and render second, the component’s getter is the latest write and overwrites the mock. The test still passes when the component happens to register the same value, and breaks subtly when it doesn’t. Treat “mocks come after render” as a hard rule. See Unit testing.
Ownership and scopes
Section titled “Ownership and scopes”Scopes change which slot a registration goes into; they do not change the policy inside a slot. Inside one scope, last-write-wins. Across scopes, the slots are independent.
If the three panels truly are independent instances, each should be wrapped in its own scope id — chat-panel:general, chat-panel:random, chat-panel:hiring. The trigger’s scope: 'chat-panel' is one declaration; the scope id on the React side is what carves the slots.
See Scopes for the full story; ownership and scopes compose orthogonally.
Disposal semantics
Section titled “Disposal semantics”Two things can clear a registration:
- The component that registered it unmounts. Standard React effect cleanup; the runtime’s
RegistrationToken.unregister()runs. If the slot is still occupied by this registration, the slot is cleared; if a later write has already overwritten it, the unregister is a silent no-op (so a stale cleanup never wipes a fresh registration). - The trigger is re-registered with the same id. Last-mount-wins applies to triggers too — re-registering a trigger drops every in-flight run, cancels every timer, and the new trigger starts with empty condition/action slots. This is mostly a hot-reload concern; in production you rarely call
createTriggermore than once for the same id.
The runtime is not responsible for clearing conditions / actions when their owning trigger is replaced — the replacement starts empty, and the components re-register on their effect cycle. If a component skips its effect cycle entirely (an unusual hot-reload edge), its registration is gone. This is intentional: it makes hot reload predictable rather than smart.
Future strategies (opt-in, post-1.0)
Section titled “Future strategies (opt-in, post-1.0)”v1.0 ships one strategy: last-write-wins. The roadmap includes three opt-in alternatives. They are sketched here so you can read the API as it lands.
stackable
Section titled “stackable”A registration provides a value or a partial; the runtime merges all live registrations with a user-provided combiner.
Use case: feature flags assembled from several sources, telemetry tags accumulated from feature-level providers, etc. Today the right shape is “merge before registering” — see the SettingsProvider example above.
first-wins
Section titled “first-wins”The first registration on a (trigger, name) pair stays; subsequent registrations are no-ops (still warned in DEV).
Use case: an app shell that wants its provider to be canonical even when sub-features try to override.
strict
Section titled “strict”A second registration throws synchronously. Useful in tests where two providers mounted at once is always a bug.
Today, approximate this in tests with a custom Middleware.onSkip check, or assert that the DEV warning was not emitted.
Anti-patterns
Section titled “Anti-patterns”Anti-pattern: relying on registration order
Section titled “Anti-pattern: relying on registration order”If the right answer depends on the render order, it is brittle. Lift the decision up and pick a single provider. The render-order outcome is deterministic for a given tree, but it is not what most readers will guess from the JSX.
Anti-pattern: re-mounting to “refresh” a value
Section titled “Anti-pattern: re-mounting to “refresh” a value”useCondition already reads through its getter on every fire — there is no cache. Re-mounting to refresh is a no-op for ownership semantics (the new mount becomes the top, but the value would have updated anyway because the getter closes over fresh state via deps). If you find yourself re-keying for this reason, the getter’s deps array is probably wrong.
Anti-pattern: silencing the DEV warning
Section titled “Anti-pattern: silencing the DEV warning”The warn-once is a one-time line in the console. If you “fix” it by stopping the second mount with an if — that is the right fix. If you “fix” it by adding console.warn = noop to your test setup, the warning will be back for the next collision, and you will have lost the signal that this collision exists.
Reviewer checklist
Section titled “Reviewer checklist”- If you see a DEV
multiple ... registrationswarning, are both registrations supposed to be alive at the same time, or is one of them a forgotten mount? - For tests: does the test call
rt.mockCondition/rt.mockActionafterrender(...)? If it calls them before, the component’s getter is on top and the mock is shadowed. - For multi-instance UIs: is each instance wrapped in its own
<TriggerScope>so the slots don’t collide? - For app-shell
requiredconditions: is exactly one component responsible for registering them?