@kuindji/reactive
Advanced tools
+9
-6
@@ -148,8 +148,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
| 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()) { | ||
| // Re-throw unless the failure is handled. It is handled when a live | ||
| // error listener exists now, OR when the action was destroyed | ||
| // mid-flight after starting with listeners: destroy() tears the | ||
| // listeners down, and that teardown must not flip a previously-handled | ||
| // failure into a rejection. The start-of-invoke snapshot is scoped to | ||
| // the destroyed case on purpose — an ordinary removeErrorListener() | ||
| // while awaiting genuinely leaves the failure unhandled, so invoke() | ||
| // must reject rather than silently swallow it. | ||
| if (!hasErrorListeners() && !(destroyed && handlesErrors)) { | ||
| throw error; | ||
@@ -156,0 +159,0 @@ } |
@@ -18,2 +18,16 @@ import type { EventOptions, ListenerOptions } from "../event.js"; | ||
| /** | ||
| * Expand a listener's options into a fully-populated set of the soft fields | ||
| * that {@link ListenerOptions} reconciliation updates in place, filling every | ||
| * omitted field with its default. | ||
| * | ||
| * `event.updateListenerOptions` uses partial-merge semantics (only fields | ||
| * present in the passed object change), but the React reconciliation layer is | ||
| * declarative: the options object fully describes the desired listener state, | ||
| * so a field dropped between renders must reset to its default. Passing this | ||
| * normalized object makes partial-merge behave as a full reset for exactly the | ||
| * soft fields — without disturbing `signal` (identity-managed by the abort | ||
| * wiring) or `context` (identity, handled by resubscribe). | ||
| */ | ||
| export declare function fillListenerUpdateDefaults(options?: ListenerOptions | null): ListenerOptions; | ||
| /** | ||
| * Domain-specific comparator for {@link EventOptions}. Primitives compare after | ||
@@ -20,0 +34,0 @@ * default semantics; `filter`/`filterContext` compare by reference. |
@@ -74,2 +74,28 @@ function normalizeAsync(value) { | ||
| /** | ||
| * Expand a listener's options into a fully-populated set of the soft fields | ||
| * that {@link ListenerOptions} reconciliation updates in place, filling every | ||
| * omitted field with its default. | ||
| * | ||
| * `event.updateListenerOptions` uses partial-merge semantics (only fields | ||
| * present in the passed object change), but the React reconciliation layer is | ||
| * declarative: the options object fully describes the desired listener state, | ||
| * so a field dropped between renders must reset to its default. Passing this | ||
| * normalized object makes partial-merge behave as a full reset for exactly the | ||
| * soft fields — without disturbing `signal` (identity-managed by the abort | ||
| * wiring) or `context` (identity, handled by resubscribe). | ||
| */ | ||
| export function fillListenerUpdateDefaults(options) { | ||
| var _a, _b, _c, _d, _e, _f, _g; | ||
| const o = options !== null && options !== void 0 ? options : {}; | ||
| return { | ||
| limit: (_a = o.limit) !== null && _a !== void 0 ? _a : 0, | ||
| start: (_b = o.start) !== null && _b !== void 0 ? _b : 1, | ||
| tags: (_c = o.tags) !== null && _c !== void 0 ? _c : [], | ||
| extraData: (_d = o.extraData) !== null && _d !== void 0 ? _d : null, | ||
| alwaysFirst: (_e = o.alwaysFirst) !== null && _e !== void 0 ? _e : false, | ||
| alwaysLast: (_f = o.alwaysLast) !== null && _f !== void 0 ? _f : false, | ||
| async: (_g = o.async) !== null && _g !== void 0 ? _g : null, | ||
| }; | ||
| } | ||
| /** | ||
| * Domain-specific comparator for {@link EventOptions}. Primitives compare after | ||
@@ -76,0 +102,0 @@ * default semantics; `filter`/`filterContext` compare by reference. |
| 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"; | ||
| import { type AsyncActionState } from "./useAsyncAction.js"; | ||
| export type { ActionStatus, AsyncActionState }; | ||
@@ -6,0 +6,0 @@ /** |
| import { useCallback, useSyncExternalStore } from "react"; | ||
| import { toAsyncActionState } from "./useAsyncAction.js"; | ||
| /** | ||
@@ -21,7 +22,3 @@ * Subscribes to the status of a named action on an ActionBus and returns | ||
| const status = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); | ||
| return { | ||
| loading: status.pending, | ||
| error: status.error, | ||
| response: status.response, | ||
| }; | ||
| return toAsyncActionState(status); | ||
| } |
@@ -10,2 +10,8 @@ import type { ActionResponse, ActionStatus } from "../action.js"; | ||
| /** | ||
| * Project an action's {@link ActionStatus} into the {@link AsyncActionState} | ||
| * shape consumed by the status hooks (`pending` -> `loading`). Shared so | ||
| * `useAsyncAction` and `useActionBusStatus` stay in lockstep. | ||
| */ | ||
| export declare function toAsyncActionState<Response>(status: ActionStatus<Response>): AsyncActionState<Response>; | ||
| /** | ||
| * Wraps a function in an action and exposes its in-flight status, so a | ||
@@ -12,0 +18,0 @@ * component can drive `loading`/`disabled` without a hand-rolled |
| import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from "react"; | ||
| import { createAction } from "../action.js"; | ||
| /** | ||
| * Project an action's {@link ActionStatus} into the {@link AsyncActionState} | ||
| * shape consumed by the status hooks (`pending` -> `loading`). Shared so | ||
| * `useAsyncAction` and `useActionBusStatus` stay in lockstep. | ||
| */ | ||
| export function toAsyncActionState(status) { | ||
| return { | ||
| loading: status.pending, | ||
| error: status.error, | ||
| response: status.response, | ||
| }; | ||
| } | ||
| /** | ||
| * Wraps a function in an action and exposes its in-flight status, so a | ||
@@ -47,8 +59,4 @@ * component can drive `loading`/`disabled` without a hand-rolled | ||
| invoke, | ||
| { | ||
| loading: status.pending, | ||
| error: status.error, | ||
| response: status.response, | ||
| }, | ||
| toAsyncActionState(status), | ||
| ]; | ||
| } |
| import { useEffect, useRef } from "react"; | ||
| import { areListenerOptionsEqual } from "./listenerOptionsEqual.js"; | ||
| import { areListenerOptionsEqual, fillListenerUpdateDefaults, } from "./listenerOptionsEqual.js"; | ||
| /** | ||
@@ -40,3 +40,12 @@ * Reconciles a single reactive listener across renders without relying on the | ||
| if (!areListenerOptionsEqual(committedRef.current, options)) { | ||
| update(context, options); | ||
| // Normalize to a full soft-option set so a field dropped since the | ||
| // last render resets to its default: updateListenerOptions is | ||
| // partial-merge, but the React path is declarative (see | ||
| // fillListenerUpdateDefaults). Carry `signal` through only when | ||
| // present (matching the old whole-options pass), so its abort wiring | ||
| // is rebound/cleared exactly as before; `context` is passed | ||
| // separately and not read from the options object. | ||
| update(context, Object.assign(Object.assign({}, fillListenerUpdateDefaults(options)), ((options === null || options === void 0 ? void 0 : options.signal) !== undefined | ||
| ? { signal: options.signal } | ||
| : {}))); | ||
| } | ||
@@ -43,0 +52,0 @@ committedRef.current = options; |
@@ -125,2 +125,11 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from "react"; | ||
| const subscribe = useCallback((onStoreChange) => { | ||
| // subscribe can run (during commit) for an already-destroyed store | ||
| // — e.g. a provider torn down before this component mounts, or a | ||
| // `store` change re-running subscribe. control() reaches the | ||
| // destroyed control bus's addListener, which throws "EventBus is | ||
| // destroyed" out of render. Skip subscribing and hand back a no-op | ||
| // cleanup; getSelection already reads destroyed stores safely. | ||
| if (store.isDestroyed()) { | ||
| return () => { }; | ||
| } | ||
| const listener = (names) => { | ||
@@ -127,0 +136,0 @@ const currentDeps = depsRef.current; |
| import { useCallback, useSyncExternalStore } from "react"; | ||
| export function useStoreState(store, key) { | ||
| const subscribe = useCallback((onStoreChange) => { | ||
| // subscribe can run (during commit) for a store that was already | ||
| // destroyed — e.g. a provider torn down before this component | ||
| // mounts, or a `key`/`store` change re-running subscribe. onChange | ||
| // reaches the destroyed changes event's addListener, which throws | ||
| // "Event is destroyed" out of render. Skip subscribing and hand back | ||
| // a no-op cleanup; getSnapshot already reads destroyed stores safely. | ||
| if (store.isDestroyed()) { | ||
| return () => { }; | ||
| } | ||
| const listener = () => { | ||
@@ -5,0 +14,0 @@ onStoreChange(); |
+50
-55
@@ -83,9 +83,16 @@ import { createEventBus } from "./eventBus.js"; | ||
| }; | ||
| const _set = (name, value, triggerChange = true) => { | ||
| const _set = (name, value, triggerChange = true, runBeforeChange = true) => { | ||
| var _a, _b, _c, _d, _e; | ||
| const prev = data.get(name); | ||
| if (prev !== value) { | ||
| const beforeChangeResults = control.all(BeforeChangeEventName, name, value); | ||
| if (beforeChangeResults.some((result) => result === false)) { | ||
| return; | ||
| // A computed recompute skips the beforeChange veto (runBeforeChange | ||
| // false): the initial computed seed() bypasses beforeChange for the | ||
| // same reason, so allowing a veto here would leave the derived value | ||
| // stale and no longer equal to fn(deps) — internally inconsistent | ||
| // with the seeded value. beforeChange still gates ordinary sets. | ||
| if (runBeforeChange) { | ||
| const beforeChangeResults = control.all(BeforeChangeEventName, name, value); | ||
| if (beforeChangeResults.some((result) => result === false)) { | ||
| return; | ||
| } | ||
| } | ||
@@ -203,2 +210,18 @@ const pipeArgs = [value]; | ||
| function asyncSet(name, value) { | ||
| // Validate computed keys synchronously, mirroring set(). Deferring the | ||
| // check to the timer callback would turn a misuse into an uncaught | ||
| // exception escaping the timer (crashing Node / surfacing as an uncaught | ||
| // browser error) instead of a catchable throw at the call site. | ||
| if (typeof name === "string") { | ||
| if (computedKeys.has(name)) { | ||
| throw new Error(`Cannot set computed property "${name}"`); | ||
| } | ||
| } | ||
| else if (typeof name === "object" && name !== null) { | ||
| for (const k of Object.keys(name)) { | ||
| if (computedKeys.has(k)) { | ||
| throw new Error(`Cannot set computed property "${k}"`); | ||
| } | ||
| } | ||
| } | ||
| const timer = setTimeout(() => { | ||
@@ -219,8 +242,10 @@ pendingTimers.delete(timer); | ||
| } | ||
| // 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) => { | ||
| // Coalesce a change log per key and replay it as one onChange each, keeping | ||
| // the first entry's pre-cascade `prev` and the last entry's settled `value` | ||
| // and dropping keys whose net value is unchanged. Store-change errors route | ||
| // to the error event; an unhandled one propagates unless the surrounding | ||
| // callback already failed (`hasCallbackError`), in which case it is swallowed | ||
| // so the original callback error is the one that ultimately surfaces. Shared | ||
| // by batch() and the single-set cascade wrapper so both coalesce identically. | ||
| const replayCoalescedLog = (log, hasCallbackError) => { | ||
| var _a; | ||
@@ -266,2 +291,7 @@ const coalesced = new Map(); | ||
| } | ||
| }; | ||
| // Replay a coalesced cascade log and, if the driving callback failed, rethrow | ||
| // its error last (after every surviving onChange has fired). | ||
| const replayCoalescedChanges = (log, hasCallbackError, callbackError) => { | ||
| replayCoalescedLog(log, hasCallbackError); | ||
| if (hasCallbackError) { | ||
@@ -468,3 +498,3 @@ throw callbackError; | ||
| const batch = (fn) => { | ||
| var _a, _b; | ||
| var _a; | ||
| if (batching) { | ||
@@ -506,44 +536,6 @@ throw new Error("Nested batch() calls are not supported"); | ||
| // 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 = [ | ||
| 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; | ||
| } | ||
| } | ||
| // intermediate value. Drop keys whose net value is unchanged from before | ||
| // the batch. The callback error (if any) is deferred and rethrown at the | ||
| // end so the partial-write control change event below still fires. | ||
| replayCoalescedLog(log, hasCallbackError); | ||
| // Dedupe so the control change event lists each key once, matching the | ||
@@ -565,3 +557,3 @@ // non-batch path (which dedupes via `dedupe([name, ...effectKeys])`). | ||
| }); | ||
| if ((_b = control.get(ErrorEventName)) === null || _b === void 0 ? void 0 : _b.hasListener()) { | ||
| if ((_a = control.get(ErrorEventName)) === null || _a === void 0 ? void 0 : _a.hasListener()) { | ||
| if (hasCallbackError) { | ||
@@ -680,3 +672,6 @@ throw callbackError; | ||
| try { | ||
| _set(key, fn(...depValues)); | ||
| // runBeforeChange=false: a computed key is derived and must | ||
| // always hold fn(deps); a beforeChange veto here would strand a | ||
| // stale value (see _set and the seed() rationale). | ||
| _set(key, fn(...depValues), true, false); | ||
| } | ||
@@ -683,0 +678,0 @@ finally { |
+1
-1
| { | ||
| "name": "@kuindji/reactive", | ||
| "version": "1.2.0", | ||
| "version": "1.3.0", | ||
| "author": "Ivan Kuindzhi", | ||
@@ -5,0 +5,0 @@ "type": "module", |
264607
2.17%4839
1.6%