@kuindji/reactive
Advanced tools
| 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 */ |
+168
-4
@@ -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; |
+34
-1
@@ -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>>; |
+255
-42
@@ -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; |
+106
-3
@@ -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, |
@@ -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; |
+3
-0
| 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"; |
+3
-0
| 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 @@ } |
+3
-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; |
+410
-43
@@ -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, |
+1
-1
| { | ||
| "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 @@ |
258975
37.4%68
9.68%4763
39.39%1142
12.07%