🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@kuindji/reactive

Package Overview
Dependencies
Maintainers
1
Versions
28
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@kuindji/reactive - npm Package Compare versions

Comparing version
1.1.0
to
1.2.0
+13
dist/react/useActionBusStatus.d.ts
import type { ActionStatus } from "../action.js";
import type { BaseActionBus } from "../actionBus.js";
import type { KeyOf } from "../lib/types.js";
import type { AsyncActionState } from "./useAsyncAction.js";
export type { ActionStatus, AsyncActionState };
/**
* Subscribes to the status of a named action on an ActionBus and returns
* `{ loading, error, response }` for driving `loading`/`disabled` UI. This is
* the primary path for apps that route mutations through one shared ActionBus.
*
* An unregistered name reports an idle status and is safe to subscribe to.
*/
export declare function useActionBusStatus<TBus extends BaseActionBus, TName extends KeyOf<TBus["__type"]["actions"]>>(bus: TBus, name: TName): AsyncActionState<TBus["__type"]["actions"][TName]["actionReturnType"]>;
import { useCallback, useSyncExternalStore } from "react";
/**
* Subscribes to the status of a named action on an ActionBus and returns
* `{ loading, error, response }` for driving `loading`/`disabled` UI. This is
* the primary path for apps that route mutations through one shared ActionBus.
*
* An unregistered name reports an idle status and is safe to subscribe to.
*/
export function useActionBusStatus(bus, name) {
const subscribe = useCallback((onChange) => {
const listener = () => {
onChange();
};
bus.onStatusChange(name, listener);
return () => {
bus.removeStatusListener(name, listener);
};
}, [bus, name]);
const getSnapshot = useCallback(() => bus.getStatus(name), [bus, name]);
const status = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return {
loading: status.pending,
error: status.error,
response: status.response,
};
}
import type { ActionResponse, ActionStatus } from "../action.js";
import type { BaseHandler } from "../lib/types.js";
export type { ActionResponse, ActionStatus };
export type AsyncActionState<Response = any> = {
loading: boolean;
error: Error | null;
response: Response | null;
};
/**
* Wraps a function in an action and exposes its in-flight status, so a
* component can drive `loading`/`disabled` without a hand-rolled
* `useState(false)`. Returns `[invoke, { loading, error, response }]`.
*
* For the common app pattern (one shared ActionBus) prefer
* `useActionBusStatus`. This hook is for a standalone, component-local action.
*/
export declare function useAsyncAction<Fn extends BaseHandler>(fn: Fn): readonly [
(...args: Parameters<Fn>) => Promise<ActionResponse<Awaited<ReturnType<Fn>>, Parameters<Fn>>>,
AsyncActionState<Awaited<ReturnType<Fn>>>
];
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from "react";
import { createAction } from "../action.js";
/**
* Wraps a function in an action and exposes its in-flight status, so a
* component can drive `loading`/`disabled` without a hand-rolled
* `useState(false)`. Returns `[invoke, { loading, error, response }]`.
*
* For the common app pattern (one shared ActionBus) prefer
* `useActionBusStatus`. This hook is for a standalone, component-local action.
*/
export function useAsyncAction(fn) {
// Keep the latest fn in a ref. The action wraps a stable indirection that
// always calls fnRef.current, so it invokes the current fn even from a
// consumer layout effect that runs after a rerender but before this hook's
// passive effects — which a useEffect+setAction swap would miss, invoking
// the previous fn. The ref is updated in a layout effect (commit phase),
// not during render: a render-phase mutation would leak a fn from a
// suspended or abandoned concurrent render that never commits into the
// currently committed UI. Layout effects run only for committed renders,
// and this one runs before any consumer layout effect declared after the
// hook call, so consumers still observe the latest fn.
const fnRef = useRef(fn);
useLayoutEffect(() => {
fnRef.current = fn;
});
const action = useMemo(() => {
const action = createAction(((...args) => fnRef.current(...args)));
// Without an error listener a throwing fn re-throws out of invoke
// (an unhandled rejection) instead of surfacing through status.
action.addErrorListener(() => { });
return action;
}, []);
const subscribe = useCallback((onChange) => {
const listener = () => {
onChange();
};
action.onStatusChange(listener);
return () => {
action.removeStatusListener(listener);
};
}, [action]);
const getSnapshot = useCallback(() => action.getStatus(), [action]);
const status = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const invoke = useCallback((...args) => action.invoke(...args), [action]);
return [
invoke,
{
loading: status.pending,
error: status.error,
response: status.response,
},
];
}
import type { KeyOf } from "../lib/types.js";
import type { BaseStore } from "../store.js";
export type EqualityFn<T> = (a: T, b: T) => boolean;
/**
* Subscribes to a derived slice of a store with custom equality. Selector
* re-execution is gated on the raw input (dep values, or a shallow compare of
* the full state), so a selector that builds a fresh object each call returns a
* stable cached reference while its input is unchanged — safe even without an
* equality fn (an un-gated fresh reference on every getSnapshot call would loop
* forever). The optional equality fn additionally bails React re-renders when a
* recompute produces an equal-but-fresh result.
*
* Two forms:
* useStoreSelector(store, (s) => `${s.first} ${s.last}`, shallowEqual?)
* useStoreSelector(store, ["first", "last"], (first, last) => …, eqFn?)
*
* The deps-keyed form recomputes only when the change batch touches its keys.
*
* Prefer the deps-keyed form for narrow reads. The selector form (no deps)
* subscribes to every store change and rebuilds the whole state via getData()
* on each one, re-running the selector even for unrelated writes (the equality
* fn still bails React re-renders, but the recompute itself is not filtered).
* The deps-keyed form both filters the subscription to its keys and avoids
* materializing the full state, so reach for it when selecting a few slices of
* a large or frequently-written store.
*
* Concurrent-safe: the selection is memoized in a render-phase `useMemo` (an
* abandoned concurrent render discards it rather than leaking it into the
* committed tree) and the committed value is recorded in an effect, not during
* render. This mirrors React's own `useSyncExternalStoreWithSelector`.
*/
export declare function useStoreSelector<TStore extends BaseStore, R>(store: TStore, selector: (state: TStore["__type"]["propTypes"]) => R, equalityFn?: EqualityFn<R>): R;
export declare function useStoreSelector<TStore extends BaseStore, const D extends readonly KeyOf<TStore["__type"]["propTypes"]>[], R>(store: TStore, deps: D, selector: (...values: {
[I in keyof D]: TStore["__type"]["propTypes"][D[I]];
}) => R, equalityFn?: EqualityFn<R>): R;
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from "react";
import { ChangeEventName } from "../store.js";
// Shallow per-entry equality for two state objects (enumerable own keys
// compared with Object.is). Used to gate selector re-execution in the
// no-deps form, whose input is rebuilt fresh by getData() on every call.
function shallowEqualObject(a, b) {
if (a === b) {
return true;
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) {
return false;
}
for (const key of aKeys) {
if (!Object.prototype.hasOwnProperty.call(b, key)
|| !Object.is(a[key], b[key])) {
return false;
}
}
return true;
}
export function useStoreSelector(store, arg2, arg3, arg4) {
var _a;
const deps = Array.isArray(arg2) ? arg2 : null;
const selector = (deps ? arg3 : arg2);
const equalityFn = (_a = (deps ? arg4 : arg3)) !== null && _a !== void 0 ? _a : Object.is;
// Committed selection cache. Written ONLY in an effect (commit phase) so an
// abandoned concurrent render cannot leak its selection into the committed
// tree (which a render-phase write to this cache would).
const instRef = useRef(null);
if (instRef.current === null) {
instRef.current = { hasValue: false, value: null };
}
const inst = instRef.current;
// Latest deps for the subscribe filter. Updated in a layout effect (commit
// phase), not during render, and read only inside the change listener (which
// fires after commit), so the subscription always filters on committed deps.
const depsRef = useRef(deps);
useLayoutEffect(() => {
depsRef.current = deps;
});
// The memoized selection getter. Built during render, but the useMemo result
// is part of the fiber's memoized state: an abandoned concurrent render
// discards it, so no closure leaks. Rebuilt only when the store, selector,
// equality, or deps identity changes. The committed `inst.value` is read
// (never written) here, so a re-render with an equal result bails out to the
// committed reference.
const getSelection = useMemo(() => {
let hasMemo = false;
let memoizedInput = [];
let memoized;
// Read the raw selector input (dep values, or the full state). On a
// destroyed store, read via getData() (which returns {} without
// asserting) instead of store.get() (which throws): getSnapshot can
// run for a still-mounted component after the store is destroyed
// (e.g. a provider torn down first), and must not throw out of
// render. Returns the input as an arg array so it can be both
// shallow-compared and spread into the selector.
const readInput = () => {
if (deps) {
if (store.isDestroyed()) {
const snapshot = store.getData();
return deps.map((d) => snapshot[d]);
}
return deps.map((d) => store.get(d));
}
return [store.getData()];
};
// Compare two raw inputs. The deps form holds dep values, compared
// by identity (the store replaces values on change, so identity
// tracks change). The selector form holds a single full-state
// object rebuilt fresh by getData() on every call, so it must be
// shallow-compared by entries rather than reference.
const inputsEqual = (a, b) => {
if (deps) {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (!Object.is(a[i], b[i])) {
return false;
}
}
return true;
}
return shallowEqualObject(a[0], b[0]);
};
return () => {
const input = readInput();
if (!hasMemo) {
hasMemo = true;
memoizedInput = input;
const next = selector(...input);
if (inst.hasValue && equalityFn(inst.value, next)) {
memoized = inst.value;
return inst.value;
}
memoized = next;
return next;
}
// Gate selector re-execution on the raw input. getSnapshot must
// return a cached reference that only changes when the store
// changes; re-running a fresh-object selector on every call (and
// relying solely on equalityFn) would return a new reference
// each call under the default Object.is and loop forever. When
// the input is unchanged we return the cached selection without
// re-running the selector — mirroring React's own
// useSyncExternalStoreWithSelector, which gates on snapshot
// identity (here the store's reads are not stable references, so
// we shallow-compare the input instead).
if (inputsEqual(memoizedInput, input)) {
return memoized;
}
memoizedInput = input;
const next = selector(...input);
if (equalityFn(memoized, next)) {
return memoized;
}
memoized = next;
return next;
};
}, [store, selector, equalityFn, deps, inst]);
const subscribe = useCallback((onStoreChange) => {
const listener = (names) => {
const currentDeps = depsRef.current;
if (currentDeps
&& !names.some((n) => currentDeps.indexOf(n) !== -1)) {
return;
}
onStoreChange();
};
store.control(ChangeEventName, listener);
return () => {
store.removeControl(ChangeEventName, listener);
};
}, [store]);
const value = useSyncExternalStore(subscribe, getSelection, getSelection);
useEffect(() => {
inst.hasValue = true;
inst.value = value;
}, [value, inst]);
return value;
}
+19
-0

@@ -12,2 +12,14 @@ import type { ApiType, BaseHandler, ErrorListenerSignature, ErrorResponse } from "./lib/types.js";

export type ListenerSignature<ActionSignature extends BaseHandler> = (arg: ActionResponse<Awaited<ReturnType<ActionSignature>>, Parameters<ActionSignature>>) => void;
/**
* Status of an action's `invoke` lifecycle, suitable for driving
* `loading`/`disabled` UI. `pending` is true while one or more invocations are
* in flight; `response`/`error` hold the last settled outcome (a before-veto
* settles to neither). This is not a cache — `response` is just the last value.
*/
export type ActionStatus<Response = any> = {
pending: boolean;
error: Error | null;
response: Response | null;
};
export type StatusListenerSignature<ActionSignature extends BaseHandler> = (status: ActionStatus<Awaited<ReturnType<ActionSignature>>>) => void;
export type BeforeActionSignature<ActionSignature extends BaseHandler> = (...args: Parameters<ActionSignature>) => false | void | Promise<false | void>;

@@ -25,2 +37,4 @@ export type ActionDefinitionHelper<A extends BaseHandler> = {

errorListenerSignature: ErrorListenerSignature<Parameters<A>>;
statusType: ActionStatus<Awaited<ReturnType<A>>>;
statusListenerSignature: StatusListenerSignature<A>;
};

@@ -30,2 +44,7 @@ export declare function createAction<A extends BaseHandler>(action: A): ApiType<ActionDefinitionHelper<A>, {

readonly setAction: (nextAction: A) => void;
readonly destroy: () => void;
readonly isDestroyed: () => boolean;
readonly getStatus: () => ActionStatus<Awaited<ReturnType<A>>>;
readonly onStatusChange: (handler: StatusListenerSignature<A>, listenerOptions?: import("./event.js").ListenerOptions) => void;
readonly removeStatusListener: (handler: StatusListenerSignature<A>, context?: object | null, tag?: string | null) => boolean;
readonly addListener: (handler: ListenerSignature<A>, listenerOptions?: import("./event.js").ListenerOptions) => void;

@@ -32,0 +51,0 @@ /** @alias addListener */

+137
-14

@@ -17,6 +17,82 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {

let actionFn = action;
const { trigger, addListener, removeAllListeners, removeListener, updateListenerOptions, promise, } = createEvent();
const { all: triggerBeforeAction, addListener: addBeforeActionListener, removeAllListeners: removeAllBeforeActionListeners, removeListener: removeBeforeActionListener, promise: beforeActionPromise, } = createEvent();
const { trigger: triggerError, addListener: addErrorListener, removeAllListeners: removeAllErrorListeners, removeListener: removeErrorListener, promise: errorPromise, hasListener: hasErrorListeners, } = createEvent();
const { trigger, addListener, removeAllListeners, removeListener, updateListenerOptions, promise, destroy: destroyResponseEvent, } = createEvent();
const { all: triggerBeforeAction, addListener: addBeforeActionListener, removeAllListeners: removeAllBeforeActionListeners, removeListener: removeBeforeActionListener, promise: beforeActionPromise, destroy: destroyBeforeEvent, } = createEvent();
const { trigger: triggerError, addListener: addErrorListener, removeAllListeners: removeAllErrorListeners, removeListener: removeErrorListener, promise: errorPromise, hasListener: hasErrorListeners, destroy: destroyErrorEvent, } = createEvent();
// Status is a side channel over the invoke lifecycle: a dedicated event so
// a React hook can subscribe through useSyncExternalStore. The status
// object reference is kept stable and only rebuilt when a field actually
// changes, which is required for useSyncExternalStore to bail out of
// redundant renders.
const { trigger: triggerStatus, addListener: addStatusListener, removeListener: removeStatusListener, destroy: destroyStatusEvent, } = createEvent();
let destroyed = false;
let inFlight = 0;
let lastResponse = null;
let lastError = null;
// Frozen so getStatus() can hand out the live reference (required for
// useSyncExternalStore to bail out of redundant renders) without a consumer
// being able to mutate it — a tampered `pending` would make updateStatus()
// believe nothing changed and suppress the next notification.
let currentStatus = Object.freeze({
pending: false,
error: null,
response: null,
});
const updateStatus = () => {
const pending = inFlight > 0;
if (currentStatus.pending === pending
&& currentStatus.error === lastError
&& currentStatus.response === lastResponse) {
return;
}
currentStatus = Object.freeze({
pending,
error: lastError,
response: lastResponse,
});
// The status event may have been torn down while an invocation was in
// flight. Still reconcile `currentStatus` above (so getStatus() does not
// strand `pending: true` after a mid-flight destroy), but skip emitting
// onto the dead event, which would throw and mask the real outcome.
if (destroyed) {
return;
}
// Status is a side channel. A throwing status listener must not corrupt
// the invoke lifecycle: if it propagated here it would, depending on the
// call site, abort execution or skip the inFlight decrement and strand
// `pending: true`. Isolate it and surface it via the error event.
try {
triggerStatus(currentStatus);
}
catch (error) {
// Surface the failure via the error event, but a throwing error
// listener must not re-escape either: this runs before invoke()
// enters its try/finally, so any escape would strand pending:true
// and skip execution entirely.
try {
triggerError({
error: error instanceof Error
? error
: new Error(String(error)),
args: [],
type: "action-status",
});
}
catch (_a) {
// Nothing left to route to; swallow to protect the lifecycle.
}
}
};
const getStatus = () => currentStatus;
const invoke = (...args) => __awaiter(this, void 0, void 0, function* () {
if (destroyed) {
throw new Error("Action is destroyed");
}
// Snapshot whether error listeners existed at invocation start. The
// catch below ORs this with a live re-check: the snapshot guards against
// destroy() tearing listeners down mid-flight (which must not flip a
// handled failure into a rejection), while the live check still routes
// the error to a listener registered after invoke() began.
const handlesErrors = hasErrorListeners();
inFlight++;
updateStatus();
try {

@@ -29,2 +105,7 @@ const beforeResponse = triggerBeforeAction(...args);

if (before === false) {
// A before-veto is a no-op for the caller, not a settlement:
// leave lastResponse/lastError untouched so a vetoed
// invocation cannot wipe the status of a concurrent (or
// prior) real invocation. A fresh action still reads idle
// because both start null.
const response = {

@@ -35,3 +116,8 @@ response: null,

};
trigger(response);
// Skip emitting if destroyed mid-flight: the caller still
// gets its settled response, but the torn-down event is not
// triggered (which would throw "Event is destroyed").
if (!destroyed) {
trigger(response);
}
return response;

@@ -44,2 +130,4 @@ }

}
lastResponse = result;
lastError = null;
const response = {

@@ -50,7 +138,22 @@ response: result,

};
trigger(response);
// A successful invocation must still resolve with its result even if
// the action was destroyed while awaiting; only skip the emit.
if (!destroyed) {
trigger(response);
}
return response;
}
catch (error) {
if (!hasErrorListeners()) {
// Record the failure before the re-throw branch so status is
// correct even when invoke re-throws (no error listener).
lastError = error instanceof Error
? error
: new Error(error);
lastResponse = null;
// Handle the error if listeners existed at invoke start OR were
// registered while the invocation was in flight. The start-of-invoke
// snapshot is retained (rather than relying solely on the live check)
// so that destroy() tearing the listeners down mid-flight cannot flip
// a previously-handled failure into a rejection.
if (!handlesErrors && !hasErrorListeners()) {
throw error;

@@ -63,12 +166,16 @@ }

};
trigger(response);
triggerError({
error: error instanceof Error
? error
: new Error(error),
args: args,
type: "action",
});
if (!destroyed) {
trigger(response);
triggerError({
error: lastError,
args: args,
type: "action",
});
}
return response;
}
finally {
inFlight--;
updateStatus();
}
});

@@ -78,5 +185,21 @@ const setAction = (nextAction) => {

};
// One-call teardown: destroy the underlying response/before/error/status
// events and mark the action dead. Post-destroy invoke/addListener throw
// rather than silently no-op.
const destroy = () => {
destroyResponseEvent();
destroyBeforeEvent();
destroyErrorEvent();
destroyStatusEvent();
destroyed = true;
};
const isDestroyed = () => destroyed;
const api = {
invoke,
setAction,
destroy,
isDestroyed,
getStatus,
onStatusChange: addStatusListener,
removeStatusListener,
addListener,

@@ -83,0 +206,0 @@ /** @alias addListener */

@@ -24,2 +24,7 @@ import { ActionDefinitionHelper, createAction } from "./action.js";

readonly invoke: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, ...args: GetActionDefinitionsMap<ActionsMap>[K]["actionArguments"]) => Promise<import("./action.js").ActionResponse<Awaited<ReturnType<ActionsMap[K]>>, Parameters<ActionsMap[K]>>>;
readonly destroy: () => void;
readonly isDestroyed: () => boolean;
readonly getStatus: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K) => GetActionDefinitionsMap<ActionsMap>[K]["statusType"];
readonly onStatusChange: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, handler: GetActionDefinitionsMap<ActionsMap>[K]["statusListenerSignature"]) => void;
readonly removeStatusListener: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, handler: GetActionDefinitionsMap<ActionsMap>[K]["statusListenerSignature"]) => boolean | undefined;
readonly addListener: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, handler: GetActionDefinitionsMap<ActionsMap>[K]["listenerSignature"], options?: ListenerOptions) => void;

@@ -26,0 +31,0 @@ /** @alias addListener */

@@ -6,2 +6,14 @@ import { createAction } from "./action.js";

const errorEvent = createEvent();
let destroyed = false;
// The error-forwarding listener attached to each action's error event is
// retained per name so removeAction can detach it. Otherwise a held action
// reference keeps forwarding errors into the bus after removal, and — since
// the forwarder counts as an error listener — its invoke resolves with an
// error response instead of rejecting.
const errorForwarders = new Map();
// Status subscriptions for actions that are not registered yet. They are
// recorded here so a later add()/replace() can attach them — otherwise a
// hook subscribing before registration (e.g. useActionBusStatus) would stay
// unsubscribed forever. Kept after attach so a re-added action reattaches.
const pendingStatusListeners = new Map();
if (errorListener) {

@@ -11,8 +23,24 @@ errorEvent.addListener(errorListener);

const add = (name, action) => {
if (destroyed) {
throw new Error("ActionBus is destroyed");
}
if (!actions.has(name)) {
const a = createAction(action);
a.addErrorListener(({ error, args }) => {
errorEvent.emit({ name, error, args, type: "action" });
});
// Preserve the original error type (e.g. "action-status") rather
// than hardcoding "action", so consumers can distinguish a failed
// invocation from a throwing status listener.
const forwarder = ({ error, args, type }) => {
errorEvent.emit({ name, error, args, type });
};
errorForwarders.set(name, forwarder);
a.addErrorListener(forwarder);
actions.set(name, a);
// Attach any status subscriptions that were registered before this
// action existed.
const pending = pendingStatusListeners.get(name);
if (pending) {
pending.forEach((handler) => {
a.onStatusChange(handler);
});
}
}

@@ -33,2 +61,5 @@ };

const replace = (name, action) => {
if (destroyed) {
throw new Error("ActionBus is destroyed");
}
const existing = actions.get(name);

@@ -46,5 +77,56 @@ if (existing) {

const removeAction = (name) => {
actions.delete(name);
const action = actions.get(name);
const existed = actions.delete(name);
// Detach the bus error-forwarding listener from the removed action so a
// held reference no longer feeds the bus error event (and so its invoke
// rejects rather than resolving via the forwarder acting as an error
// listener). A later re-add() installs a fresh forwarder.
const forwarder = errorForwarders.get(name);
if (forwarder) {
action === null || action === void 0 ? void 0 : action.removeErrorListener(forwarder);
errorForwarders.delete(name);
}
// getStatus() now reports idle for this name, but status subscribers
// (e.g. useActionBusStatus via useSyncExternalStore) were attached to
// the removed action's own status event and will never be notified of
// the drop. Detach each retained subscription from the removed action
// (otherwise invoking a held action reference keeps notifying a
// listener that bus.removeStatusListener can no longer reach), then push
// an idle status so they re-read and clear stale state. Subscriptions
// stay in pendingStatusListeners so a later re-add() reattaches them.
if (existed) {
const pending = pendingStatusListeners.get(name);
pending === null || pending === void 0 ? void 0 : pending.forEach((handler) => {
action === null || action === void 0 ? void 0 : action.removeStatusListener(handler);
// Isolate each notify: one throwing subscriber must not abort
// the loop and leave the remaining subscribers attached and
// un-notified. Route the failure to the bus error event.
try {
handler(idleStatus);
}
catch (error) {
// The error forwarding must not re-escape either: a throwing
// bus error listener would abort the loop and leave later
// subscribers attached and un-notified.
try {
errorEvent.emit({
name,
error: error instanceof Error
? error
: new Error(String(error)),
args: [],
type: "action-status",
});
}
catch (_a) {
// Nothing left to route to; swallow to keep the loop going.
}
}
});
}
};
const invoke = (name, ...args) => {
if (destroyed) {
throw new Error("ActionBus is destroyed");
}
const action = get(name);

@@ -57,2 +139,5 @@ if (!action) {

const on = (name, handler, options) => {
if (destroyed) {
throw new Error("ActionBus is destroyed");
}
const action = get(name);

@@ -65,2 +150,5 @@ if (!action) {

const once = (name, handler, options) => {
if (destroyed) {
throw new Error("ActionBus is destroyed");
}
options = options || {};

@@ -75,2 +163,5 @@ options.limit = 1;

const un = (name, handler, context, tag) => {
if (destroyed) {
throw new Error("ActionBus is destroyed");
}
const action = get(name);

@@ -83,2 +174,5 @@ if (!action) {

const updateListenerOptions = (name, handler, context, nextOptions) => {
if (destroyed) {
throw new Error("ActionBus is destroyed");
}
const action = get(name);

@@ -90,2 +184,67 @@ if (!action) {

};
// Frozen and shared: getStatus() hands this single object to every missing
// action, so a consumer mutating it would corrupt all future idle snapshots
// (e.g. leaving a hook reporting false loading). Matches action status,
// whose snapshots are also frozen.
const idleStatus = Object.freeze({
pending: false,
error: null,
response: null,
});
// Status lives on the underlying action (the single in-flight point);
// the bus just delegates per name. An unregistered name reports idle and
// is a no-op to (un)subscribe.
const getStatus = (name) => {
const action = get(name);
if (!action) {
return idleStatus;
}
return action.getStatus();
};
const onStatusChange = (name, handler) => {
if (destroyed) {
throw new Error("ActionBus is destroyed");
}
// Record the subscription so it survives (re)registration, then attach
// to the action now if it already exists.
let pending = pendingStatusListeners.get(name);
if (!pending) {
pending = new Set();
pendingStatusListeners.set(name, pending);
}
pending.add(handler);
const action = get(name);
if (!action) {
return;
}
return action.onStatusChange(handler);
};
const removeStatusListener = (name, handler) => {
const pending = pendingStatusListeners.get(name);
if (pending) {
pending.delete(handler);
if (pending.size === 0) {
pendingStatusListeners.delete(name);
}
}
const action = get(name);
if (!action) {
return;
}
return action.removeStatusListener(handler);
};
// One-call teardown: destroy each owned action and the error event, then
// drop them all. Post-destroy invoke/addListener throw rather than silently
// no-op.
const destroy = () => {
actions.forEach((action) => {
action.destroy();
});
actions.clear();
pendingStatusListeners.clear();
errorForwarders.clear();
errorEvent.destroy();
destroyed = true;
};
const isDestroyed = () => destroyed;
const api = {

@@ -98,2 +257,7 @@ add,

invoke,
destroy,
isDestroyed,
getStatus,
onStatusChange,
removeStatusListener,
addListener: on,

@@ -100,0 +264,0 @@ /** @alias addListener */

@@ -8,2 +8,7 @@ import type { ActionResponse } from "./action.js";

readonly setAction: (nextAction: M[key]) => void;
readonly destroy: () => void;
readonly isDestroyed: () => boolean;
readonly getStatus: () => import("./action.js").ActionStatus<Awaited<ReturnType<M[key]>>>;
readonly onStatusChange: (handler: import("./action.js").StatusListenerSignature<M[key]>, listenerOptions?: import("./event.js").ListenerOptions) => void;
readonly removeStatusListener: (handler: import("./action.js").StatusListenerSignature<M[key]>, context?: object | null, tag?: string | null) => boolean;
readonly addListener: (handler: import("./action.js").ListenerSignature<M[key]>, listenerOptions?: import("./event.js").ListenerOptions) => void;

@@ -10,0 +15,0 @@ readonly on: (handler: import("./action.js").ListenerSignature<M[key]>, listenerOptions?: import("./event.js").ListenerOptions) => void;

@@ -47,4 +47,9 @@ import type { ApiType, BaseHandler, ErrorListenerSignature } from "./lib/types.js";

extraData?: any;
/**
* When provided, the listener is auto-removed once the signal aborts. If the
* signal is already aborted the listener is not added at all.
*/
signal?: AbortSignal | null;
}
interface ListenerPrototype<Handler extends BaseHandler> extends Required<ListenerOptions> {
interface ListenerPrototype<Handler extends BaseHandler> extends Required<Omit<ListenerOptions, "signal">> {
handler: Handler;

@@ -55,3 +60,24 @@ called: number;

start: number;
abortCleanup: (() => void) | null;
}
/**
* Read-only projection of a registered listener, returned by `getListeners()`.
* Carries the listener's options plus its live `called`/`count` counters but
* none of the mutable internals — mutating this object does not affect the
* event.
*/
export interface ListenerInfo<Handler extends BaseHandler = BaseHandler> {
handler: Handler;
context: object | null;
tags: string[];
limit: number;
start: number;
called: number;
count: number;
async: boolean | number | null;
first: boolean;
alwaysFirst: boolean;
alwaysLast: boolean;
extraData: any;
}
export interface EventOptions<ListenerSignature extends BaseHandler> extends BaseOptions {

@@ -97,2 +123,3 @@ /**

readonly subscribe: (handler: ListenerSignature, listenerOptions?: ListenerOptions) => void;
readonly once: (handler: ListenerSignature, listenerOptions?: ListenerOptions) => void;
readonly removeListener: (handler: ListenerSignature, context?: object | null, tag?: string | null) => boolean;

@@ -123,4 +150,10 @@ readonly updateListenerOptions: (handler: ListenerSignature, context?: object | null, nextOptions?: ListenerOptions) => boolean;

readonly reset: () => void;
readonly destroy: () => void;
readonly isDestroyed: () => boolean;
readonly isSuspended: () => boolean;
readonly isQueued: () => boolean;
readonly listenerCount: (tag?: string | null) => number;
readonly triggeredCount: () => number;
readonly lastTriggerArgs: () => Parameters<ListenerSignature> | null;
readonly getListeners: () => ListenerInfo<ListenerSignature>[];
readonly withTags: <R>(tags: string[], callback: () => R) => R;

@@ -127,0 +160,0 @@ readonly promise: (options?: ListenerOptions) => Promise<Parameters<ListenerSignature>>;

@@ -12,4 +12,10 @@ import asyncCall from "./lib/asyncCall.js";

let queued = false;
let destroyed = false;
let triggered = 0;
let lastTrigger = null;
// The args replayed to a late autoTrigger listener. Recorded only while
// autoTrigger is enabled, so enabling it *after* a trigger does not replay
// that earlier (pre-enablement) trigger. Kept separate from `lastTrigger`,
// which is recorded on every trigger purely for `lastTriggerArgs`.
let autoTriggerArgs = null;
let sortListeners = false;

@@ -19,7 +25,14 @@ let currentTagsFilter = null;

const addListener = (handler, listenerOptions = {}) => {
var _a;
var _a, _b;
if (destroyed) {
throw new Error("Event is destroyed");
}
if (!handler) {
return;
}
const listenerContext = (_a = listenerOptions.context) !== null && _a !== void 0 ? _a : null;
const signal = (_a = listenerOptions.signal) !== null && _a !== void 0 ? _a : null;
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
return;
}
const listenerContext = (_b = listenerOptions.context) !== null && _b !== void 0 ? _b : null;
if (listeners.find((l) => l.handler === handler && l.context === listenerContext)) {

@@ -31,3 +44,3 @@ return;

}
const listener = Object.assign({ handler, called: 0, count: 0, index: listeners.length, start: 1, context: null, tags: [], extraData: null, first: false, alwaysFirst: false, alwaysLast: false, limit: 0, async: null }, listenerOptions);
const listener = Object.assign(Object.assign({ handler, called: 0, count: 0, index: listeners.length, start: 1, context: null, tags: [], extraData: null, first: false, alwaysFirst: false, alwaysLast: false, limit: 0, async: null }, listenerOptions), { abortCleanup: null });
if (listener.async === true) {

@@ -53,21 +66,28 @@ listener.async = 1;

}
if (signal) {
const onAbort = () => {
removeListener(handler, listenerContext);
};
signal.addEventListener("abort", onAbort, { once: true });
listener.abortCleanup = () => {
signal.removeEventListener("abort", onAbort);
};
}
if (options.autoTrigger
&& lastTrigger !== null
&& autoTriggerArgs !== null
&& !suspended) {
const prevFilter = options.filter;
options.filter = (args, l) => {
if (l && l.handler === handler) {
return prevFilter ? prevFilter(args, l) !== false : true;
}
return false;
};
try {
_trigger(lastTrigger);
}
finally {
options.filter = prevFilter;
}
// Replay the last enabled trigger, but only into the listener just
// added. The replay target is passed explicitly to `_trigger` (not
// via shared closure/`options.filter` state) so that a real trigger
// fired synchronously by the replayed handler is an ordinary real
// trigger — full bookkeeping, limit enforcement, and delivery to all
// listeners — rather than inheriting this replay's suppression.
_trigger(autoTriggerArgs, null, null, {
handler,
context: listenerContext !== null && listenerContext !== void 0 ? listenerContext : null,
});
}
};
const removeListener = (handler, context, tag) => {
var _a;
const inx = listeners.findIndex((l) => {

@@ -91,7 +111,8 @@ if (l.handler !== handler) {

}
listeners.splice(inx, 1);
const [removed] = listeners.splice(inx, 1);
(_a = removed === null || removed === void 0 ? void 0 : removed.abortCleanup) === null || _a === void 0 ? void 0 : _a.call(removed);
return true;
};
const updateListenerOptions = (handler, context = null, nextOptions = {}) => {
var _a, _b, _c, _d, _e, _f, _g;
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
const listenerContext = context !== null && context !== void 0 ? context : null;

@@ -104,15 +125,31 @@ const listener = listeners.find((l) => l.handler === handler && l.context === listenerContext);

const prevAlwaysLast = listener.alwaysLast;
// Soft fields, applying the same defaults as addListener so that a
// removed field resets to its default rather than lingering.
listener.limit = (_a = nextOptions.limit) !== null && _a !== void 0 ? _a : 0;
listener.start = (_b = nextOptions.start) !== null && _b !== void 0 ? _b : 1;
listener.tags = (_c = nextOptions.tags) !== null && _c !== void 0 ? _c : [];
listener.extraData = (_d = nextOptions.extraData) !== null && _d !== void 0 ? _d : null;
listener.alwaysFirst = (_e = nextOptions.alwaysFirst) !== null && _e !== void 0 ? _e : false;
listener.alwaysLast = (_f = nextOptions.alwaysLast) !== null && _f !== void 0 ? _f : false;
let nextAsync = (_g = nextOptions.async) !== null && _g !== void 0 ? _g : null;
if (nextAsync === true) {
nextAsync = 1;
// Partial update: only fields explicitly present in nextOptions change;
// any omitted field keeps its current value (a caller changing one
// option does not silently reset the others). Pass a field explicitly
// (e.g. limit: 0, signal: null) to clear it.
if ("limit" in nextOptions) {
listener.limit = (_a = nextOptions.limit) !== null && _a !== void 0 ? _a : 0;
}
listener.async = nextAsync;
if ("start" in nextOptions) {
listener.start = (_b = nextOptions.start) !== null && _b !== void 0 ? _b : 1;
}
if ("tags" in nextOptions) {
listener.tags = (_c = nextOptions.tags) !== null && _c !== void 0 ? _c : [];
}
if ("extraData" in nextOptions) {
listener.extraData = (_d = nextOptions.extraData) !== null && _d !== void 0 ? _d : null;
}
if ("alwaysFirst" in nextOptions) {
listener.alwaysFirst = (_e = nextOptions.alwaysFirst) !== null && _e !== void 0 ? _e : false;
}
if ("alwaysLast" in nextOptions) {
listener.alwaysLast = (_f = nextOptions.alwaysLast) !== null && _f !== void 0 ? _f : false;
}
if ("async" in nextOptions) {
let nextAsync = (_g = nextOptions.async) !== null && _g !== void 0 ? _g : null;
if (nextAsync === true) {
nextAsync = 1;
}
listener.async = nextAsync;
}
// Re-sort if ordering hints changed. Unlike addListener we do NOT

@@ -131,2 +168,27 @@ // rewrite each listener's index here: the existing indices hold the

}
// Rebind the AbortSignal only when `signal` is explicitly present:
// detach any previous wiring so the old controller can no longer remove
// this listener, then attach the new signal. Omitting the field leaves
// the existing binding intact (partial-update convention); pass
// signal: null to clear it. An already-aborted new signal removes the
// listener now, mirroring addListener's "do not keep an aborted-signal
// listener".
if ("signal" in nextOptions) {
(_h = listener.abortCleanup) === null || _h === void 0 ? void 0 : _h.call(listener);
listener.abortCleanup = null;
const nextSignal = (_j = nextOptions.signal) !== null && _j !== void 0 ? _j : null;
if (nextSignal) {
if (nextSignal.aborted) {
removeListener(listener.handler, listenerContext);
return true;
}
const onAbort = () => {
removeListener(listener.handler, listenerContext);
};
nextSignal.addEventListener("abort", onAbort, { once: true });
listener.abortCleanup = () => {
nextSignal.removeEventListener("abort", onAbort);
};
}
}
// The core auto-remove check is a strict `called === limit`, so a

@@ -165,6 +227,12 @@ // listener whose `called` already exceeds the new limit would never

listeners = listeners.filter((l) => {
return !l.tags || l.tags.indexOf(tag) === -1;
var _a;
const keep = !l.tags || l.tags.indexOf(tag) === -1;
if (!keep) {
(_a = l.abortCleanup) === null || _a === void 0 ? void 0 : _a.call(l);
}
return keep;
});
}
else {
listeners.forEach((l) => { var _a; return (_a = l.abortCleanup) === null || _a === void 0 ? void 0 : _a.call(l); });
listeners = [];

@@ -208,3 +276,94 @@ }

const isQueued = () => queued;
// One-call teardown: drop all listeners (unwinding their abort handlers via
// reset) and mark the event dead. Post-destroy trigger/addListener throw
// rather than silently no-op, surfacing use-after-free.
const destroy = () => {
reset();
destroyed = true;
};
const isDestroyed = () => destroyed;
const listenerCount = (tag) => {
if (tag) {
return listeners.filter((l) => l.tags && l.tags.indexOf(tag) !== -1).length;
}
return listeners.length;
};
const triggeredCount = () => triggered;
// Return a copy: handing back the internal `lastTrigger` reference would let
// a caller mutate it, corrupting both the recorded snapshot and the values
// replayed to autoTrigger listeners.
const lastTriggerArgs = () => lastTrigger ? lastTrigger.slice() : null;
// Deep-copy extraData so the read-only projection cannot mutate internal
// listener metadata (which filters can read) at any depth; a shallow copy
// still shares nested containers by reference. `seen` carries already-cloned
// containers so a cyclic graph reuses its clone instead of recursing forever
// (a plain recursive clone throws RangeError on cycles). Arrays, plain
// objects, Date, Map and Set are cloned. Truly opaque values (functions,
// class instances) are returned as-is — copying their enumerable keys would
// not faithfully reproduce them — as are primitives.
const projectExtraDataDeep = (value, seen) => {
if (value === null || typeof value !== "object") {
return value;
}
const existing = seen.get(value);
if (existing !== undefined) {
return existing;
}
if (value instanceof Date) {
return new Date(value.getTime());
}
if (Array.isArray(value)) {
const copy = [];
seen.set(value, copy);
for (const v of value) {
copy.push(projectExtraDataDeep(v, seen));
}
return copy;
}
if (value instanceof Map) {
const copy = new Map();
seen.set(value, copy);
value.forEach((v, k) => {
copy.set(projectExtraDataDeep(k, seen), projectExtraDataDeep(v, seen));
});
return copy;
}
if (value instanceof Set) {
const copy = new Set();
seen.set(value, copy);
value.forEach((v) => {
copy.add(projectExtraDataDeep(v, seen));
});
return copy;
}
const proto = Object.getPrototypeOf(value);
if (proto === Object.prototype || proto === null) {
const copy = {};
seen.set(value, copy);
for (const k of Object.keys(value)) {
copy[k] = projectExtraDataDeep(value[k], seen);
}
return copy;
}
return value;
};
const projectExtraData = (value) => projectExtraDataDeep(value, new WeakMap());
const getListeners = () => {
return listeners.map((l) => ({
handler: l.handler,
context: l.context,
tags: l.tags ? l.tags.slice() : [],
limit: l.limit,
start: l.start,
called: l.called,
count: l.count,
async: l.async,
first: l.first,
alwaysFirst: l.alwaysFirst,
alwaysLast: l.alwaysLast,
extraData: projectExtraData(l.extraData),
}));
};
const reset = () => {
listeners.forEach((l) => { var _a; return (_a = l.abortCleanup) === null || _a === void 0 ? void 0 : _a.call(l); });
listeners.length = 0;

@@ -217,2 +376,3 @@ errorListeners.length = 0;

lastTrigger = null;
autoTriggerArgs = null;
sortListeners = false;

@@ -279,2 +439,8 @@ };

if (returnType === TriggerReturnType.PIPE) {
// Copy-on-write: preserve the pre-pipe lastTrigger snapshot before
// mutating args[0] in place for the pipe chain (lastTrigger stores
// the args reference rather than an eager per-trigger copy).
if (lastTrigger === args) {
lastTrigger = args.slice();
}
args[0] = prevValue;

@@ -301,4 +467,12 @@ // since we don't user listener's arg transformer,

};
const _trigger = (args, returnType = null, tags) => {
var _a, _b;
const _trigger = (args, returnType = null, tags,
// When set, this call is an autoTrigger replay: it must not bump
// `triggered`/`lastTrigger` or be gated by the trigger `limit`, and it is
// delivered only to the listener identified here.
replayTo) => {
var _a, _b, _c;
const replaying = !!replayTo;
if (destroyed) {
throw new Error("Event is destroyed");
}
if (queued) {

@@ -315,8 +489,24 @@ queue.push([

}
if (options.limit && triggered >= options.limit) {
// The trigger `limit` bounds real triggers; an autoTrigger replay is an
// internal redelivery and must always reach the new listener.
if (options.limit && triggered >= options.limit && !replaying) {
return;
}
triggered++;
if (options.autoTrigger) {
lastTrigger = args.slice();
if (!replaying) {
triggered++;
// Record the last trigger arguments for introspection
// (`lastTriggerArgs`). Store the reference rather than eagerly
// copying on every trigger (a hot path even when nothing ever reads
// it): `args` is a fresh per-call array the caller cannot reach, and
// listeners receive it spread (never the array itself). The snapshot
// is copied lazily — when handed out by lastTriggerArgs(), and
// copy-on-write before PIPE mode mutates args[0] in place — so the
// recorded snapshot stays the pre-pipe arguments.
lastTrigger = args;
// Record the replay source only while autoTrigger is enabled, so a
// late listener added after autoTrigger is turned on replays the
// most recent *enabled* trigger, never an earlier disabled one.
if (options.autoTrigger) {
autoTriggerArgs = args.slice();
}
}

@@ -352,2 +542,9 @@ // in pipe mode if there is no listeners,

}
// An autoTrigger replay is delivered only to the newly added
// listener identified by `replayTo`.
if (replayTo
&& (listener.handler !== replayTo.handler
|| ((_c = listener.context) !== null && _c !== void 0 ? _c : null) !== replayTo.context)) {
continue;
}
if (options.filter

@@ -374,2 +571,12 @@ && options.filter.call(options.filterContext, args, listener)

}
// Count the call and exhaust the limit BEFORE invoking the handler.
// If the handler re-triggers this same event, the nested _trigger
// snapshots `listeners` AFTER the removal below, so an exhausted
// (e.g. once()) listener is not invoked a second time. Doing this
// after the call would let a re-entrant trigger see the still-live
// listener and run it again past its limit.
listener.called++;
if (listener.called === listener.limit) {
removeListener(listener.handler, listener.context);
}
if (isConsequent && results.length > 0) {

@@ -392,6 +599,2 @@ const prev = results[results.length - 1];

}
listener.called++;
if (listener.called === listener.limit) {
removeListener(listener.handler, listener.context);
}
if (returnType === TriggerReturnType.FIRST) {

@@ -488,2 +691,5 @@ return listenerResult;

};
const once = (handler, listenerOptions = {}) => {
return addListener(handler, Object.assign(Object.assign({}, listenerOptions), { limit: 1 }));
};
const promise = (options) => {

@@ -585,2 +791,3 @@ return new Promise((resolve) => {

subscribe: addListener,
once,
removeListener,

@@ -611,4 +818,10 @@ updateListenerOptions,

reset,
destroy,
isDestroyed,
isSuspended,
isQueued,
listenerCount,
triggeredCount,
lastTriggerArgs,
getListeners,
withTags,

@@ -615,0 +828,0 @@ promise,

@@ -94,2 +94,4 @@ import { createEvent } from "./event.js";

readonly reset: () => void;
readonly destroy: () => void;
readonly isDestroyed: () => boolean;
readonly suspendAll: (withQueue?: boolean) => void;

@@ -96,0 +98,0 @@ readonly resumeAll: () => void;

@@ -81,2 +81,6 @@ import { createEvent } from "./event.js";

const eventSources = [];
let destroyed = false;
// Registry of active relays so destroy() can unwind the external listeners
// they attach (reset()/destroy() otherwise leave them dangling).
const relays = [];
const asterisk = createEvent();

@@ -135,2 +139,5 @@ const errorEvent = createEvent();

const add = (name, options) => {
if (destroyed) {
throw new Error("EventBus is destroyed");
}
if (!events.has(name)) {

@@ -178,5 +185,11 @@ events.set(name, createEvent(options));

const get = (name) => {
if (destroyed) {
throw new Error("EventBus is destroyed");
}
return _getOrAddEvent(name);
};
const on = (name, handler, options) => {
if (destroyed) {
throw new Error("EventBus is destroyed");
}
const e = _getOrAddEvent(name);

@@ -205,7 +218,8 @@ eventSources.forEach((evs) => {

const once = (name, handler, options) => {
options = options || {};
options.limit = 1;
return on(name, handler, options);
return on(name, handler, Object.assign(Object.assign({}, (options || {})), { limit: 1 }));
};
const promise = (name, options) => {
if (destroyed) {
throw new Error("EventBus is destroyed");
}
const e = _getOrAddEvent(name);

@@ -246,2 +260,5 @@ return e.promise(options);

const _trigger = (name, args, returnType, resolve) => {
if (destroyed) {
throw new Error("EventBus is destroyed");
}
if (name === "*") {

@@ -401,2 +418,16 @@ return;

const reset = () => {
// Detach relays BEFORE clearing proxyListeners: unrelay() resolves the
// external listener via _getProxyListener, which depends on the original
// entry still being present. Clearing proxyListeners first would lose the
// callback identity, leaving the external subscription dangling so a
// later destroy() could never remove it.
relays.slice().forEach((r) => {
unrelay({
eventSource: r.eventSource,
remoteEventName: r.remoteEventName,
localEventName: r.localEventName,
proxyType: r.proxyType,
localEventNamePrefix: r.localEventNamePrefix,
});
});
if (eventSources.length > 0) {

@@ -407,2 +438,9 @@ eventSources.slice().forEach((evs) => {

}
// Reset each owned event before dropping it: clearing the map alone
// leaves listener AbortSignal handlers attached to their signals, which
// retains the orphaned events (and their listeners) until the signal
// aborts. reset() detaches those handlers and clears the listeners.
events.forEach((event) => {
event.reset();
});
events.clear();

@@ -414,3 +452,34 @@ interceptor = null;

eventSources.length = 0;
relays.length = 0;
};
// One-call teardown: unwind external attachments (relays + event sources)
// that reset() leaves dangling, destroy every owned event, and mark the bus
// dead. Post-destroy trigger/addListener throw rather than silently no-op.
const destroy = () => {
relays.slice().forEach((r) => {
unrelay({
eventSource: r.eventSource,
remoteEventName: r.remoteEventName,
localEventName: r.localEventName,
proxyType: r.proxyType,
localEventNamePrefix: r.localEventNamePrefix,
});
});
eventSources.slice().forEach((evs) => {
removeEventSource(evs.eventSource);
});
events.forEach((event) => {
event.destroy();
});
events.clear();
asterisk.destroy();
errorEvent.destroy();
proxyListeners.length = 0;
eventSources.length = 0;
relays.length = 0;
interceptor = null;
currentTagsFilter = null;
destroyed = true;
};
const isDestroyed = () => destroyed;
const suspendAll = (withQueue = false) => {

@@ -427,2 +496,8 @@ events.forEach((event) => {

const relay = ({ eventSource, remoteEventName, localEventName, proxyType, localEventNamePrefix, }) => {
// Like the other registration methods, refuse on a dead bus: attaching
// the proxy listener here would leave a dangling subscription that
// throws "EventBus is destroyed" the next time the source fires.
if (destroyed) {
throw new Error("EventBus is destroyed");
}
const { returnType, resolve } = proxyReturnTypeToTriggerReturnType(proxyType || ProxyType.TRIGGER);

@@ -442,2 +517,14 @@ const listener = _getProxyListener({

}
relays.push({
eventSource,
remoteEventName,
localEventName: localEventName || null,
// Store the resolved proxyType (undefined resolves to TRIGGER) so the
// registry key matches how the proxy listener is actually resolved.
// Otherwise unrelay({ proxyType: TRIGGER }) for a relay({}) (undefined)
// detaches the listener but leaves a stale registry entry, which a
// later reset()/destroy() then unrelays a second time.
proxyType: proxyType || ProxyType.TRIGGER,
localEventNamePrefix: localEventNamePrefix || null,
});
};

@@ -461,4 +548,18 @@ const unrelay = ({ eventSource, remoteEventName, localEventName, proxyType, localEventNamePrefix, }) => {

}
const inx = relays.findIndex((r) => r.eventSource === eventSource
&& r.remoteEventName === remoteEventName
&& r.localEventName === (localEventName || null)
// Compare on the resolved proxyType so an undefined relay matches an
// explicit TRIGGER unrelay (and vice versa), mirroring how the proxy
// listener itself resolves equivalent types.
&& r.proxyType === (proxyType || ProxyType.TRIGGER)
&& r.localEventNamePrefix === (localEventNamePrefix || null));
if (inx !== -1) {
relays.splice(inx, 1);
}
};
const addEventSource = (eventSource) => {
if (destroyed) {
throw new Error("EventBus is destroyed");
}
if (eventSources.find((evs) => evs.eventSource.name === eventSource.name)) {

@@ -563,2 +664,4 @@ return;

reset,
destroy,
isDestroyed,
suspendAll,

@@ -565,0 +668,0 @@ resumeAll,

+1
-1

@@ -47,4 +47,4 @@ export type MapKey = string;

name?: MapKey;
type: "action" | "event" | "store-change" | "store-pipe" | "store-control";
type: "action" | "action-status" | "event" | "store-change" | "store-pipe" | "store-control";
};
export type ErrorListenerSignature<Arguments extends any[] = any[]> = (errorResponse: ErrorResponse<Arguments>) => void;
export * from "./react/ErrorBoundary.js";
export * from "./react/useAction.js";
export * from "./react/useActionBus.js";
export * from "./react/useActionBusStatus.js";
export * from "./react/useAsyncAction.js";
export * from "./react/useActionMap.js";

@@ -13,2 +15,3 @@ export * from "./react/useEvent.js";

export * from "./react/useStore.js";
export * from "./react/useStoreSelector.js";
export * from "./react/useStoreState.js";
export * from "./react/ErrorBoundary.js";
export * from "./react/useAction.js";
export * from "./react/useActionBus.js";
export * from "./react/useActionBusStatus.js";
export * from "./react/useAsyncAction.js";
export * from "./react/useActionMap.js";

@@ -13,2 +15,3 @@ export * from "./react/useEvent.js";

export * from "./react/useStore.js";
export * from "./react/useStoreSelector.js";
export * from "./react/useStoreState.js";

@@ -12,6 +12,18 @@ import { useCallback, useSyncExternalStore } from "react";

}, [store, key]);
const getSnapshot = useCallback(() => store.get(key), [store, key]);
const getSnapshot = useCallback(
// getSnapshot can run for a still-mounted component after the store is
// destroyed (e.g. a provider torn down first), and must not throw out of
// render. On a destroyed store read via getData() (returns {} without
// asserting) instead of get() (which throws). Mirrors useStoreSelector.
() => (store.isDestroyed()
? store.getData()[key]
: store.get(key)), [store, key]);
const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const setter = useCallback((value) => {
if (typeof value === "function") {
// The cast is required by tsc (the typeof-narrowed `value` is
// `Setter | (ValueType & Function)`, not all callable), even
// though no-unnecessary-type-assertion disagrees on this TS
// version.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
store.set(key, value(store.get(key)));

@@ -18,0 +30,0 @@ }

@@ -45,4 +45,7 @@ import { EventBusDefinitionHelper } from "./eventBus.js";

};
readonly computed: <K extends KeyOf<PropMap>, const D extends readonly KeyOf<PropMap>[]>(key: K, deps: D, fn: (...values: { [I in keyof D]: PropMap[D[I]] | undefined; }) => PropMap[K]) => void;
readonly isEmpty: () => boolean;
readonly reset: () => void;
readonly destroy: () => void;
readonly isDestroyed: () => boolean;
readonly onChange: <K extends KeyOf<import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>>, H extends import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, options?: import("./event.js").ListenerOptions) => void;

@@ -49,0 +52,0 @@ readonly removeOnChange: <K extends KeyOf<import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>>, H extends import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, context?: object | null, tag?: string | null) => void;

@@ -13,2 +13,60 @@ import { createEventBus } from "./eventBus.js";

let effectKeys = [];
// Computed keys are read-only via the public `set` and recompute via the
// `effect` control event. `computingKeys` is a per-key re-entrancy guard so
// a cyclic computed throws instead of looping forever.
const computedKeys = new Set();
const computingKeys = new Set();
// Re-seed closures keyed in registration order so reset() can recompute each
// computed value from the (now cleared) deps. Without this, reset() clears
// `data` but leaves computed keys stale/undefined while still marked
// read-only, so they no longer equal fn(deps).
const computedReseeders = new Map();
// The effect listener installed for each computed key, so re-registering the
// same key detaches the previous recompute closure instead of leaving it
// attached (which would run a stale fn on every dependency change).
const computedEffectListeners = new Map();
// Multi-key object sets write every base value first with effect emission
// deferred (`deferEffects`), then replay effects once so computed values
// recompute from the final state instead of once per intermediate write.
// `computedBatch`, when active, records the dependency values each computed
// last recomputed from in this batch. A computed is skipped only when its
// deps are unchanged since then — so a redundant dep change is a no-op, but
// a dependent in a computed chain still recomputes once its upstream
// computed updates (rather than settling on a stale early value).
let deferEffects = false;
let computedBatch = null;
const arraysShallowEqual = (a, b) => {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
};
let destroyed = false;
// Timers scheduled by asyncSet, tracked so destroy() can cancel them.
// Otherwise a pending callback fires after teardown and throws "Store is
// destroyed" from inside the timer.
const pendingTimers = new Set();
const assertAlive = () => {
if (destroyed) {
throw new Error("Store is destroyed");
}
};
const dedupe = (keys) => Array.from(new Set(keys));
// A public set() can trigger a computed cascade. Routing it through batch()
// makes that cascade glitch-free (one coalesced onChange per affected key).
// Only do so at the top level: if already batching or intercepting, the
// surrounding operation coalesces; with no effect listener there is no
// cascade to coalesce.
const canCoalesceCascade = () => {
var _a;
return !batching
&& !changes.isIntercepting()
&& !control.isIntercepting()
&& !!((_a = control.get(EffectEventName)) === null || _a === void 0 ? void 0 : _a.hasListener());
};
const effectInterceptor = (name, args) => {

@@ -19,2 +77,7 @@ if (name === ChangeEventName) {

}
// While the multi-key loop writes base values, swallow effect emissions;
// they are replayed once afterwards against the final state.
if (name === EffectEventName && deferEffects) {
return false;
}
return true;

@@ -103,2 +166,6 @@ };

}
// Clear before propagating: an unhandled throw here would
// otherwise leave the cascade's collected keys dirty for the
// next _set, which would report them as spuriously changed.
effectKeys = [];
throw error;

@@ -109,3 +176,3 @@ }

try {
control.trigger(ChangeEventName, [name, ...effectKeys]);
control.trigger(ChangeEventName, dedupe([name, ...effectKeys]));
if (!control.isIntercepting()) {

@@ -128,2 +195,5 @@ effectKeys = [];

}
// Clear before propagating (see the effect-trigger catch
// above): a leaked effectKeys would taint the next _set.
effectKeys = [];
throw error;

@@ -137,3 +207,8 @@ }

function asyncSet(name, value) {
setTimeout(() => {
const timer = setTimeout(() => {
pendingTimers.delete(timer);
// The store may have been destroyed between scheduling and firing.
if (destroyed) {
return;
}
if (typeof name === "string") {

@@ -146,54 +221,200 @@ set(name, value);

}, 0);
pendingTimers.add(timer);
}
function set(name, value) {
// Replay a coalesced change log: one onChange per key, keeping the first
// entry's pre-cascade `prev` and the last entry's settled `value`, dropping
// keys whose net value is unchanged. Mirrors batch()'s replay (including its
// store-change error routing) so a computed touched several times during a
// cascade emits a single, internally-consistent onChange.
const replayCoalescedChanges = (log, hasCallbackError, callbackError) => {
var _a;
const coalesced = new Map();
for (const [propName, value, prev] of log) {
const existing = coalesced.get(propName);
if (existing) {
existing.value = value;
}
else {
coalesced.set(propName, { value, prev });
}
}
for (const [propName, { value, prev }] of coalesced) {
if (value === prev) {
continue;
}
const changeArgs = [
value,
prev,
];
try {
changes.trigger(propName, ...changeArgs);
}
catch (error) {
control.trigger(ErrorEventName, {
error: error instanceof Error
? error
: new Error(String(error)),
args: changeArgs,
type: "store-change",
name: propName,
});
if ((_a = control.get(ErrorEventName)) === null || _a === void 0 ? void 0 : _a.hasListener()) {
continue;
}
if (hasCallbackError) {
continue;
}
throw error;
}
}
if (hasCallbackError) {
throw callbackError;
}
};
// Run `fn` with onChange emissions intercepted and coalesced. The control
// change-key collection (effectKeys / effectInterceptor ordering) inside
// `fn` is untouched, so only the per-key onChange stream is deduped.
const withChangeCoalescing = (fn, liveKey) => {
const log = [];
let liveDelivered = false;
const logger = function (propName, args) {
// Deliver the directly-set key's onChange live (it fires inside _set
// before the effect cascade) so plain onChange listeners still run
// ahead of effect listeners; only the cascade's emissions are
// coalesced. Returning a non-false value lets the interceptor pass
// the event through to its listeners.
if (!liveDelivered && liveKey !== undefined && propName === liveKey) {
liveDelivered = true;
return true;
}
log.push([propName, args[0], args[1]]);
return false;
};
changes.intercept(logger);
let callbackError;
let hasCallbackError = false;
try {
fn();
}
catch (error) {
callbackError = error;
hasCallbackError = true;
}
finally {
changes.stopIntercepting();
}
replayCoalescedChanges(log, hasCallbackError, callbackError);
};
// The write path shared by set() and its coalescing wrapper. Computed-key
// validation happens in set() before this runs.
const applySet = (name, value) => {
var _a, _b;
if (typeof name === "string") {
_set(name, value);
return;
}
else if (typeof name === "object") {
const changedKeys = [];
const isIntercepting = control.isIntercepting();
const hasEffectListener = (_a = control.get(EffectEventName)) === null || _a === void 0 ? void 0 : _a.hasListener();
const shouldInterceptEffects = hasEffectListener && !isIntercepting;
let controlError = null;
const changedKeys = [];
const isIntercepting = control.isIntercepting();
const hasEffectListener = (_a = control.get(EffectEventName)) === null || _a === void 0 ? void 0 : _a.hasListener();
const shouldInterceptEffects = hasEffectListener && !isIntercepting;
let controlError = null;
if (shouldInterceptEffects) {
control.intercept(effectInterceptor);
computedBatch = new Map();
}
let allChangedKeys = [];
try {
// Phase 1: write every base value with effect emission deferred,
// so computed values do not recompute against a half-updated
// state mid-loop.
if (shouldInterceptEffects) {
control.intercept(effectInterceptor);
deferEffects = true;
}
try {
Object.entries(name).forEach(([k, v]) => {
if (_set(k, v, false)) {
changedKeys.push(k);
}
});
const allChangedKeys = [
...changedKeys,
...effectKeys,
];
if (allChangedKeys.length > 0) {
const entries = Object.entries(name);
entries.forEach(([k, v]) => {
if (_set(k, v, false)) {
changedKeys.push(k);
}
});
// Phase 2: with all base values final, replay the effect once per
// changed key. `computedBatch` skips a computed whose dependency
// values are unchanged since its last recompute, while still
// letting chained computeds re-settle when an upstream updates.
if (shouldInterceptEffects) {
deferEffects = false;
changedKeys.forEach((k) => {
var _a;
// Mirror _set's effect error contract: a throwing effect
// listener routes to the error event (and is swallowed if
// a handler exists) rather than aborting the whole set.
try {
control.trigger(ChangeEventName, allChangedKeys);
control.trigger(EffectEventName, k, data.get(k));
}
catch (error) {
controlError = error instanceof Error
? error
: new Error(String(error));
control.trigger(ErrorEventName, {
error: error instanceof Error
? error
: new Error(String(error)),
args: [k],
type: "store-control",
name: k,
});
if (!((_a = control.get(ErrorEventName)) === null || _a === void 0 ? void 0 : _a.hasListener())) {
throw error;
}
}
}
});
}
finally {
if (shouldInterceptEffects) {
effectKeys = [];
control.stopIntercepting();
}
allChangedKeys = dedupe([
...changedKeys,
...effectKeys,
]);
}
finally {
if (shouldInterceptEffects) {
effectKeys = [];
deferEffects = false;
computedBatch = null;
control.stopIntercepting();
}
if (controlError) {
control.trigger(ErrorEventName, {
error: controlError,
args: [name],
type: "store-control",
});
if ((_b = control.get(ErrorEventName)) === null || _b === void 0 ? void 0 : _b.hasListener()) {
return;
}
// Fire the outer change AFTER intercepting stops; otherwise the
// effectInterceptor (active during the loop to fold computed/effect
// writes into effectKeys) would swallow this trigger too.
if (allChangedKeys.length > 0) {
try {
control.trigger(ChangeEventName, allChangedKeys);
}
catch (error) {
controlError = error instanceof Error
? error
: new Error(String(error));
}
}
if (controlError) {
control.trigger(ErrorEventName, {
error: controlError,
args: [name],
type: "store-control",
});
if ((_b = control.get(ErrorEventName)) === null || _b === void 0 ? void 0 : _b.hasListener()) {
return;
}
throw controlError;
}
};
function set(name, value) {
assertAlive();
if (typeof name === "string") {
if (computedKeys.has(name)) {
throw new Error(`Cannot set computed property "${name}"`);
}
}
else if (typeof name === "object") {
// Validate all keys before any write so a computed key in the patch
// throws without partially applying the others.
for (const k of Object.keys(name)) {
if (computedKeys.has(k)) {
throw new Error(`Cannot set computed property "${k}"`);
}
throw controlError;
}

@@ -204,4 +425,21 @@ }

}
// A cascade-triggering set coalesces its onChange emissions: a computed
// touched several times during the cascade (e.g. a diamond sink
// recomputed once per upstream) fires onChange once with its settled
// value and the real pre-set prev, instead of leaking an internally
// inconsistent intermediate (b_new + c_old) with a wrong prev. The
// control change-key collection is left untouched, so change-key
// ordering is unaffected.
if (canCoalesceCascade()) {
// For a single-key set, deliver that key's onChange live (before the
// effect cascade) and coalesce only the downstream computed-key
// emissions. A multi-key (object) set is an explicit batch, so all
// of its onChange emissions remain coalesced.
withChangeCoalescing(() => applySet(name, value), typeof name === "string" ? name : undefined);
return;
}
applySet(name, value);
}
const get = (key) => {
assertAlive();
if (typeof key === "string") {

@@ -268,3 +506,22 @@ const value = data.get(key);

}
// Coalesce the log per key before replaying: a key written multiple
// times in the batch (e.g. a computed recomputing once per base-key
// write) must fire onChange once with its final value, not once per
// intermediate value. Keep first-occurrence order, the pre-batch `prev`
// from the first entry, and the final `value` from the last entry; drop
// keys whose net value is unchanged from before the batch.
const coalesced = new Map();
for (const [propName, value, prev] of log) {
const existing = coalesced.get(propName);
if (existing) {
existing.value = value;
}
else {
coalesced.set(propName, { value, prev });
}
}
for (const [propName, { value, prev }] of coalesced) {
if (value === prev) {
continue;
}
const changeArgs = [

@@ -295,5 +552,9 @@ value,

}
if (allChangedKeys.length > 0) {
// Dedupe so the control change event lists each key once, matching the
// non-batch path (which dedupes via `dedupe([name, ...effectKeys])`).
// A computed touched by several base-key writes would otherwise repeat.
const dedupedChangedKeys = dedupe(allChangedKeys);
if (dedupedChangedKeys.length > 0) {
try {
control.trigger(ChangeEventName, allChangedKeys);
control.trigger(ChangeEventName, dedupedChangedKeys);
}

@@ -305,3 +566,3 @@ catch (error) {

: new Error(String(error)),
args: [allChangedKeys],
args: [dedupedChangedKeys],
type: "store-control",

@@ -327,4 +588,107 @@ });

data.clear();
// Recompute computed keys from the cleared deps (registration order, so a
// base computed re-seeds before a dependent in a chain) and re-seed them
// silently, keeping each consistent with fn(deps) instead of stale.
computedReseeders.forEach((reseed) => {
reseed();
});
control.trigger(ResetEventName);
};
// One-call teardown: destroy the underlying change/pipe/control buses and
// drop all data. Post-destroy set/get throw rather than silently no-op.
const destroy = () => {
pendingTimers.forEach((timer) => clearTimeout(timer));
pendingTimers.clear();
changes.destroy();
pipe.destroy();
control.destroy();
data.clear();
computedKeys.clear();
computingKeys.clear();
computedReseeders.clear();
computedEffectListeners.clear();
effectKeys = [];
destroyed = true;
};
const isDestroyed = () => destroyed;
// Registers `key` as a derived value recomputed from `deps`. Built as sugar
// over the `effect` control event: recompute writes via the internal `_set`
// (triggerChange = true) so the change folds into the same outer `change`
// batch via `effectKeys`. Computed keys flow transparently through
// get/getData/onChange/useStoreState/useStoreSelector.
//
// Recompute is registration-order, not topologically sorted, so a dependent
// (chain or diamond) may recompute from a stale upstream first. The internal
// intermediate recompute is invisible to consumers: set() coalesces the
// onChange stream for a cascade, so each computed fires onChange once with
// its settled value and the real pre-set prev (`computedBatch` also dedupes
// redundant recomputes within a multi-key set). The final get()/onChange
// value is always correct.
const computed = (key, deps, fn) => {
// Bail before running user code or seeding data: on a destroyed store
// the control bus throws only when the recompute listener is attached,
// by which point fn() has already run and the initial value has been
// written, repopulating cleared state that getData() would then expose.
assertAlive();
const readDeps = () => deps.map((d) => data.get(d));
// Compute the initial value BEFORE committing any registration state.
// If `fn` throws here, nothing has been mutated, so the key does not
// become a permanently read-only computed with no listener installed.
const initialValue = fn(...readDeps());
// Seed silently (no change emitted at setup time) but through pipe, so
// the value read right after computed()/reset() matches what every
// later _set-driven recompute produces; otherwise a piped key silently
// changes shape on the first dependency change. beforeChange is skipped
// on purpose: a silent seed has no change to veto, and a computed key
// must always hold a value.
const seed = (raw) => {
const pipeArgs = [raw];
const piped = pipe.pipe(key, ...pipeArgs);
data.set(key, piped !== undefined ? piped : raw);
};
seed(initialValue);
computedKeys.add(key);
// reset() re-runs this to recompute the value from the cleared deps. It
// seeds `data` silently (no change emitted), matching this setup path.
computedReseeders.set(key, () => {
seed(fn(...readDeps()));
});
// Detach a prior recompute closure for this key (re-registration)
// before installing the new one, so the old fn does not keep running on
// every dependency change.
const prevEffect = computedEffectListeners.get(key);
if (prevEffect) {
control.removeListener(EffectEventName, prevEffect);
}
const effectListener = (changedName) => {
if (deps.indexOf(changedName) === -1) {
return;
}
const depValues = readDeps();
// Within a single multi-key set, skip the recompute only when this
// computed's dependency values are unchanged since its last
// recompute in the batch. That dedupes redundant dep changes while
// still re-settling a chain when an upstream computed updates.
if (computedBatch) {
const lastDepValues = computedBatch.get(key);
if (lastDepValues
&& arraysShallowEqual(lastDepValues, depValues)) {
return;
}
computedBatch.set(key, depValues);
}
if (computingKeys.has(key)) {
throw new Error(`Cyclic computed dependency detected at "${key}"`);
}
computingKeys.add(key);
try {
_set(key, fn(...depValues));
}
finally {
computingKeys.delete(key);
}
};
control.addListener(EffectEventName, effectListener);
computedEffectListeners.set(key, effectListener);
};
const api = {

@@ -336,4 +700,7 @@ set,

asyncSet,
computed,
isEmpty,
reset,
destroy,
isDestroyed,
onChange: changes.addListener,

@@ -340,0 +707,0 @@ removeOnChange: changes.removeListener,

{
"name": "@kuindji/reactive",
"version": "1.1.0",
"version": "1.2.0",
"author": "Ivan Kuindzhi",

@@ -5,0 +5,0 @@ "type": "module",

+128
-5

@@ -12,4 +12,4 @@ # @kuindji/reactive

- **Event System**: Event emitter with subscriber/dispatcher and collector modes
- **Action System**: Async action handling with error management and response tracking
- **Store System**: Reactive state management with change tracking and validation
- **Action System**: Async action handling with error management, response tracking and loading/error/response status
- **Store System**: Reactive state management with change tracking, validation and computed/derived values
- **EventBus**: Centralized event management for complex applications

@@ -103,5 +103,17 @@ - **ActionBus & ActionMap**: Organized action management with error handling

extraData: object, // Custom data will be passed to filter()
signal: AbortSignal, // Auto-remove the listener when this signal aborts
});
```
When a `signal` is provided, the listener is removed automatically once the
signal aborts (and is not added at all if the signal is already aborted). The
abort subscription is cleaned up if the listener is removed first, so there is no
dangling reference into a still-live signal:
```typescript
const controller = new AbortController();
event.addListener(handler, { signal: controller.signal });
controller.abort(); // handler is now removed
```
### Collector

@@ -164,5 +176,6 @@

- **Aliases**: `on()`, `listen()`, `subscribe()`
- `once(listener, options?)` - Add a listener that is removed after a single call (sugar for `addListener(listener, { ...options, limit: 1 })`)
- `removeListener(listener, context?, tag?)` - Remove specific listener
- **Aliases**: `un()`, `off()`, `remove()`, `unsubscribe()`
- `updateListenerOptions(listener, context?, nextOptions?)` - Update a registered listener's soft options (`limit`, `start`, `async`, `tags`, `extraData`, `alwaysFirst`/`alwaysLast`) **in place**, preserving its `called`/`count` counters. Matches the listener by `listener` + `context`. Returns `true` if a listener was found. `context` is an identity field and is not updated here (resubscribe to change it); `first` is insertion-time only and ignored. Lowering `limit` to at/below the current `called` removes the listener immediately.
- `updateListenerOptions(listener, context?, nextOptions?)` - Update a registered listener's soft options (`limit`, `start`, `async`, `tags`, `extraData`, `alwaysFirst`/`alwaysLast`, `signal`) **in place**, preserving its `called`/`count` counters. This is a **partial update**: only fields explicitly present in `nextOptions` change; any omitted field keeps its current value. Pass a field explicitly to clear it (e.g. `limit: 0` for unlimited, `signal: null` to drop abort wiring). Matches the listener by `listener` + `context`. Returns `true` if a listener was found. `context` is an identity field and is not updated here (resubscribe to change it); `first` is insertion-time only and ignored. Lowering `limit` to at/below the current `called` removes the listener immediately.
- `hasListener(listener?, context?, tag?)` - Check if listener exists

@@ -203,4 +216,13 @@ - **Aliases**: `has()`

- `reset()` - Reset event state
- `destroy()` - Tear down the event: remove all listeners (unwinding any `AbortSignal` subscriptions) and mark it dead. After `destroy()`, `trigger()` and `addListener()` throw rather than silently no-op.
- `isDestroyed()` - Returns `true` once `destroy()` has been called
- `withTags(tags: string[], callback: () => CallbackResponse) => CallbackResponse` - Execute callback with specific tags
#### Introspection
- `listenerCount(tag?)` - Number of registered listeners, optionally filtered by tag
- `triggeredCount()` - How many times the event has been triggered
- `lastTriggerArgs()` - The most recent trigger arguments (a copy), or `null` if never triggered
- `getListeners()` - Read-only projection of registered listeners (`handler`, `context`, `tags`, `limit`, `start`, `called`, `count`, `async`, ordering flags, `extraData`). Mutating the returned objects does not affect the event.
## EventBus

@@ -529,3 +551,5 @@

- `resumeAll()` - Resume all events
- `reset()` - Reset all events
- `reset()` - Reset all events: unrelay all relays and remove all event sources (detaching their external listeners), then clear every owned event and interception/tag state. The bus stays usable afterwards.
- `destroy()` - Tear down the bus: unrelay all relays, remove all event sources (detaching their external listeners), destroy every owned event, and mark the bus dead. After `destroy()`, `trigger()`/`on()` throw.
- `isDestroyed()` - Returns `true` once `destroy()` has been called
- `withTags(tags, callback)` - Execute callback with specific tags

@@ -579,2 +603,4 @@

- `removeAllListeners(tag?)` - Remove all listeners
- `destroy()` - Tear down the action: destroy its response, before-action, error and status events and mark it dead. After `destroy()`, `invoke()`/`addListener()` throw.
- `isDestroyed()` - Returns `true` once `destroy()` has been called

@@ -587,2 +613,26 @@ #### Error Handling

#### Status (loading / error / response)
An action tracks the status of its `invoke` lifecycle so UI can drive
`loading`/`disabled` without a hand-rolled `useState(false)`. `pending` is true
while one or more invocations are in flight; `response`/`error` hold the last
settled outcome (a before-action veto settles to neither). This is **not** a
cache — `response` is just the last value.
- `getStatus()` - Returns `{ pending: boolean, error: Error | null, response: T | null }`. The reference is stable while unchanged (safe for `useSyncExternalStore`).
- `onStatusChange(handler)` - Subscribe to status changes
- `removeStatusListener(handler)` - Remove a status listener
```typescript
const saveAction = createAction(async (data: FormData) => save(data));
saveAction.onStatusChange(({ pending, error }) => {
button.disabled = pending;
});
await saveAction.invoke(form); // pending -> true, then false on settle
```
In React, prefer the `useAsyncAction` / `useActionBusStatus` hooks (see React Hooks).
#### Utility Methods

@@ -713,3 +763,14 @@

- `updateListenerOptions(name, handler, context?, nextOptions?)` - Update a response listener's soft options in place (see Event's `updateListenerOptions`)
- `destroy()` - Tear down the bus: destroy every owned action and the error event, then drop them all. After `destroy()`, `invoke()`/`on()` throw.
- `isDestroyed()` - Returns `true` once `destroy()` has been called
#### Status (loading / error / response)
Delegates to the underlying action's status (see Action → Status). This is the
primary path for apps that route mutations through one shared ActionBus.
- `getStatus(name)` - Status for a named action; an unregistered name reports an idle status
- `onStatusChange(name, handler)` - Subscribe to a named action's status. Subscribing before the action is registered is retained and attached automatically once it is added (and re-attached if the action is later removed and re-added)
- `removeStatusListener(name, handler)` - Remove a status listener (also clears a subscription retained before registration)
#### Error Handling

@@ -772,5 +833,8 @@

- `get(keys)` - Get multiple properties
- `computed(key, deps, fn)` - Register a derived value (see Computed values)
- `isEmpty()` - Check if store is empty
- `getData()` - Get all store data
- `reset()` - Clear store data
- `reset()` - Clear store data. Computed keys are re-seeded from the cleared dependencies (so they stay consistent with `fn(deps)` rather than going stale) and remain live.
- `destroy()` - Tear down the store: destroy the underlying change/pipe/control buses and drop all data. After `destroy()`, `set()`/`get()` throw.
- `isDestroyed()` - Returns `true` once `destroy()` has been called

@@ -796,2 +860,36 @@ #### Event Methods

#### Computed values
Declare a derived key in the store type, then attach its derivation with
`computed(key, deps, fn)`. It recomputes automatically when any dependency
changes and notifies like any other key — `get`, `getData`, `onChange`,
`useStoreState` and `useStoreSelector` all see it transparently. Computed keys
are read-only: calling `set` on one throws. Computed-of-computed chains are
supported, and a cyclic computed throws rather than looping.
```typescript
type UserStore = {
first: string;
last: string;
fullName: string; // declared in the type, registered as computed
};
const store = createStore<UserStore>({ first: "Jane", last: "Doe" });
store.computed("fullName", [ "first", "last" ], (first, last) => `${first} ${last}`);
store.get("fullName"); // "Jane Doe"
store.onChange("fullName", (v) => console.log(v));
store.set("first", "John"); // fullName recomputes -> "John Doe"
store.set("fullName", "x"); // throws: computed is read-only
```
> **Note:** recompute is registration-order, not topologically sorted, so a
> chained or diamond-shaped computed may recompute internally more than once per
> change. This is invisible to consumers: a single `set(...)`/`set({...})`/`batch`
> coalesces the `onChange` stream, so each computed fires `onChange` once with
> its settled value and the correct previous value. The final value is always
> correct. Registering base computeds before dependents reduces redundant
> internal recomputes.
## React Hooks

@@ -978,2 +1076,27 @@

Select a derived slice with equality (bails out of re-renders while the result
is unchanged). Two forms — a selector over the whole state, or a deps-keyed form
that recomputes only when the listed keys change:
```typescript
// selector form (default equality is Object.is)
const label = useStoreSelector(store, (s) => `${s.first} ${s.last}`, shallowEqual?);
// deps-keyed form
const anyLoading = useStoreSelector(store, [ "a", "b", "c" ], (a, b, c) => a || b || c);
```
Drive `loading`/`disabled` from an action's status. `useActionBusStatus` is the
primary path for apps built around one shared ActionBus; `useAsyncAction` wraps a
standalone function:
```typescript
// shared ActionBus
const { loading, error, response } = useActionBusStatus(appActions, "user/login");
// standalone action
const [ submit, { loading, error } ] = useAsyncAction(saveProfileFn);
// <Button loading={loading} disabled={loading} onClick={() => submit(form)} />
```
### Reconciliation across renders

@@ -980,0 +1103,0 @@