Some concerns cut across every bloc: “user logged out → reset all session-scoped state,” “workspace switched → every open panel should re-sync.” watch/onSystemEvent describe a single container’s own lifecycle; they are the wrong tool for “broadcast this and let whoever cares react.” defineEvent/emitEvent/onEvent are the typed pub/sub for exactly that, and this.on(...) is the owner-scoped form that ties a subscription to a container’s lifetime.
This replaces “walk every live instance and call a magic method if present” bus patterns: dispatch cost here is O(subscribers for that token), never O(all live instances), and every handler is typed to the token’s payload.
A human-readable identifier for the event, used in error messages and logs.
Returns: an EventToken<P> — an opaque handle carrying the payload type P. Define tokens once, at module scope, and import them wherever you emit or listen.
Broadcast an event to every current subscriber of that token.
functionemitEvent<P>(
token: EventToken<P>,
...payload:0extends1& P // isAny
? [payload?: unknown]
: [P] extends [void]
? []
: [P]
): void;
Parameter
Type
Required
Description
token
EventToken<P>
yes
The token to dispatch on.
payload
P
when P is not void
Typed payload. Omitting it for a payload-carrying token is a compile error.
Returns:void.
Behavior. Dispatch is synchronous and runs subscribers in registration order. It costs nothing if there are no subscribers — emitEvent on a token nobody listens to is a cheap no-op, not a registry walk. Each handler runs in its own try/catch: a throwing handler is isolated (it logs to console.error and the remaining handlers still run — no handler is ever silently skipped by another’s bug). Re-entrant emitEvent/onEvent calls from inside a handler are safe: dispatch iterates a snapshot of the subscriber set taken at the start of the call, so a handler that subscribes a new listener during dispatch does not get that listener invoked for the in-flight emit.
Dispatch token to every current subscriber on the ambient registry, in
registration order.
Cost is O(subscribers for this token) — a token with no subscribers (or no
bus yet on this registry) returns immediately, never touching live
instances.
Dispatch iterates a SNAPSHOT of the handler Set, so it is safe for a
handler to synchronously call emitEvent/onEvent re-entrantly: a nested
emitEvent for the same token completes against its own snapshot, and a
handler registered via onEvent during dispatch is NOT invoked for the
in-flight emit (it only sees emits that start after it subscribed).
The variadic tuple means a void-payload token needs no second argument,
and a typed-payload token requires one. It wraps both sides in a tuple
([P] extends [void], not void extends P) because the naive form is true
for every P that void is assignable to — unknown, any, number | void — which collapsed the tuple to [] and made their payload
un-passable. any is special-cased first so it accepts either form.
emitEvent}from'@blac/core';
const
constUserLoggedOut: EventToken<{
userId: string;
}>
UserLoggedOut=
defineEvent<{
userId: string;
}>(name: string): EventToken<{
userId: string;
}>
defineEvent<{
userId:string
userId: string }>('UserLoggedOut');
emitEvent<{
userId: string;
}>(token: EventToken<{
userId: string;
}>,payload_0:{
userId: string;
}): void
Dispatch token to every current subscriber on the ambient registry, in
registration order.
Cost is O(subscribers for this token) — a token with no subscribers (or no
bus yet on this registry) returns immediately, never touching live
instances.
Dispatch iterates a SNAPSHOT of the handler Set, so it is safe for a
handler to synchronously call emitEvent/onEvent re-entrantly: a nested
emitEvent for the same token completes against its own snapshot, and a
handler registered via onEvent during dispatch is NOT invoked for the
in-flight emit (it only sees emits that start after it subscribed).
The variadic tuple means a void-payload token needs no second argument,
and a typed-payload token requires one. It wraps both sides in a tuple
([P] extends [void], not void extends P) because the naive form is true
for every P that void is assignable to — unknown, any, number | void — which collapsed the tuple to [] and made their payload
un-passable. any is special-cased first so it accepts either form.
emitEvent(
constUserLoggedOut: EventToken<{
userId: string;
}>
UserLoggedOut,{
userId:string
userId:'user-42'});
const
constAppResumed: EventToken<void>
AppResumed=
defineEvent<void>(name: string): EventToken<void>
defineEvent('AppResumed');
emitEvent<void>(token: EventToken<void>): void
Dispatch token to every current subscriber on the ambient registry, in
registration order.
Cost is O(subscribers for this token) — a token with no subscribers (or no
bus yet on this registry) returns immediately, never touching live
instances.
Dispatch iterates a SNAPSHOT of the handler Set, so it is safe for a
handler to synchronously call emitEvent/onEvent re-entrantly: a nested
emitEvent for the same token completes against its own snapshot, and a
handler registered via onEvent during dispatch is NOT invoked for the
in-flight emit (it only sees emits that start after it subscribed).
The variadic tuple means a void-payload token needs no second argument,
and a typed-payload token requires one. It wraps both sides in a tuple
([P] extends [void], not void extends P) because the naive form is true
for every P that void is assignable to — unknown, any, number | void — which collapsed the tuple to [] and made their payload
un-passable. any is special-cased first so it accepts either form.
Dispatch token to every current subscriber on the ambient registry, in
registration order.
Cost is O(subscribers for this token) — a token with no subscribers (or no
bus yet on this registry) returns immediately, never touching live
instances.
Dispatch iterates a SNAPSHOT of the handler Set, so it is safe for a
handler to synchronously call emitEvent/onEvent re-entrantly: a nested
emitEvent for the same token completes against its own snapshot, and a
handler registered via onEvent during dispatch is NOT invoked for the
in-flight emit (it only sees emits that start after it subscribed).
The variadic tuple means a void-payload token needs no second argument,
and a typed-payload token requires one. It wraps both sides in a tuple
([P] extends [void], not void extends P) because the naive form is true
for every P that void is assignable to — unknown, any, number | void — which collapsed the tuple to [] and made their payload
un-passable. any is special-cased first so it accepts either form.
Dispatch token to every current subscriber on the ambient registry, in
registration order.
Cost is O(subscribers for this token) — a token with no subscribers (or no
bus yet on this registry) returns immediately, never touching live
instances.
Dispatch iterates a SNAPSHOT of the handler Set, so it is safe for a
handler to synchronously call emitEvent/onEvent re-entrantly: a nested
emitEvent for the same token completes against its own snapshot, and a
handler registered via onEvent during dispatch is NOT invoked for the
in-flight emit (it only sees emits that start after it subscribed).
The variadic tuple means a void-payload token needs no second argument,
and a typed-payload token requires one. It wraps both sides in a tuple
([P] extends [void], not void extends P) because the naive form is true
for every P that void is assignable to — unknown, any, number | void — which collapsed the tuple to [] and made their payload
un-passable. any is special-cased first so it accepts either form.
Inside a container, prefer this.on(...) over the standalone onEvent: the subscription is automatically torn down when the container disposes, so there is no manual off to remember in consumer code.
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<
interfaceSessionState
SessionState>{
constructor(){
super({
SessionState.userId: string|null
userId:null});
// Auto-unsubscribed on dispose — no manual teardown needed here.
Subscribe to a cross-cutting application event for this container's
lifetime. The subscription is torn down automatically on dispose() — no
manual off in consumer code (requirements AC3).
Uses THIS container's registry (_registry, captured at construction), not
the ambient one, so a container always listens on the bus of the registry it
belongs to.
Behavior.this.on is protected — only callable from inside the class. It listens on this container’s own registry (the one it was constructed in), not whichever registry happens to be ambient, so a container always reacts to events on the bus of the registry it belongs to — this matters for test isolation (withTestRegistry/blacTestSetup). A disposed container never receives events: dispose fires its 'dispose' handlers (which includes the unsubscribe registered by this.on) before anything else runs, so the subscription is already gone by the time any later emit happens. A keepAlive container keeps receiving events for as long as it stays alive — nothing special to do.
Ordering. Handlers for a token run in the order they were registered.
Isolation. A throwing handler does not stop the others; the error is logged and dispatch continues.
Cost. O(subscribers for the token). Emitting an event with zero subscribers costs nothing extra — there is no per-instance walk anywhere in the path.
Re-entrancy.emitEvent called from inside a handler runs synchronously and completes before the outer dispatch resumes. A handler that registers a new onEvent/this.on during dispatch is not called for the emit that’s already in flight.
Registry isolation. Subscriptions are scoped per registry, so two independent test registries (withTestRegistry, blacTestSetup) never see each other’s subscribers.