@kuindji/reactive 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/action.js CHANGED
@@ -145,12 +145,15 @@ export function createAction(action) {
145
145
  ? error
146
146
  : new Error(error);
147
147
  lastResponse = null;
148
- // Handle the error if listeners existed at invoke start OR were
149
- // registered while the invocation was in flight. The start-of-invoke
150
- // snapshot is retained (rather than relying solely on the live check)
151
- // so that destroy() tearing the listeners down mid-flight cannot flip
152
- // a previously-handled failure into a rejection.
153
- if (!handlesErrors && !hasErrorListeners()) {
148
+ // Re-throw unless the failure is handled. It is handled when a live
149
+ // error listener exists now, OR when the action was destroyed
150
+ // mid-flight after starting with listeners: destroy() tears the
151
+ // listeners down, and that teardown must not flip a previously-handled
152
+ // failure into a rejection. The start-of-invoke snapshot is scoped to
153
+ // the destroyed case on purpose — an ordinary removeErrorListener()
154
+ // while awaiting genuinely leaves the failure unhandled, so invoke()
155
+ // must reject rather than silently swallow it.
156
+ if (!hasErrorListeners() && !(destroyed && handlesErrors)) {
154
157
  throw error;
155
158
  }
156
159
  const response = {
@@ -14,6 +14,20 @@ export declare function areTagsEqual(a?: string[], b?: string[]): boolean;
14
14
  * compare by reference, and `tags` use order-insensitive set comparison.
15
15
  */
16
16
  export declare function areListenerOptionsEqual(a?: ListenerOptions | null, b?: ListenerOptions | null): boolean;
17
+ /**
18
+ * Expand a listener's options into a fully-populated set of the soft fields
19
+ * that {@link ListenerOptions} reconciliation updates in place, filling every
20
+ * omitted field with its default.
21
+ *
22
+ * `event.updateListenerOptions` uses partial-merge semantics (only fields
23
+ * present in the passed object change), but the React reconciliation layer is
24
+ * declarative: the options object fully describes the desired listener state,
25
+ * so a field dropped between renders must reset to its default. Passing this
26
+ * normalized object makes partial-merge behave as a full reset for exactly the
27
+ * soft fields — without disturbing `signal` (identity-managed by the abort
28
+ * wiring) or `context` (identity, handled by resubscribe).
29
+ */
30
+ export declare function fillListenerUpdateDefaults(options?: ListenerOptions | null): ListenerOptions;
17
31
  /**
18
32
  * Domain-specific comparator for {@link EventOptions}. Primitives compare after
19
33
  * default semantics; `filter`/`filterContext` compare by reference.
@@ -70,6 +70,32 @@ export function areListenerOptionsEqual(a, b) {
70
70
  }
71
71
  return true;
72
72
  }
73
+ /**
74
+ * Expand a listener's options into a fully-populated set of the soft fields
75
+ * that {@link ListenerOptions} reconciliation updates in place, filling every
76
+ * omitted field with its default.
77
+ *
78
+ * `event.updateListenerOptions` uses partial-merge semantics (only fields
79
+ * present in the passed object change), but the React reconciliation layer is
80
+ * declarative: the options object fully describes the desired listener state,
81
+ * so a field dropped between renders must reset to its default. Passing this
82
+ * normalized object makes partial-merge behave as a full reset for exactly the
83
+ * soft fields — without disturbing `signal` (identity-managed by the abort
84
+ * wiring) or `context` (identity, handled by resubscribe).
85
+ */
86
+ export function fillListenerUpdateDefaults(options) {
87
+ var _a, _b, _c, _d, _e, _f, _g;
88
+ const o = options !== null && options !== void 0 ? options : {};
89
+ return {
90
+ limit: (_a = o.limit) !== null && _a !== void 0 ? _a : 0,
91
+ start: (_b = o.start) !== null && _b !== void 0 ? _b : 1,
92
+ tags: (_c = o.tags) !== null && _c !== void 0 ? _c : [],
93
+ extraData: (_d = o.extraData) !== null && _d !== void 0 ? _d : null,
94
+ alwaysFirst: (_e = o.alwaysFirst) !== null && _e !== void 0 ? _e : false,
95
+ alwaysLast: (_f = o.alwaysLast) !== null && _f !== void 0 ? _f : false,
96
+ async: (_g = o.async) !== null && _g !== void 0 ? _g : null,
97
+ };
98
+ }
73
99
  /**
74
100
  * Domain-specific comparator for {@link EventOptions}. Primitives compare after
75
101
  * default semantics; `filter`/`filterContext` compare by reference.
@@ -1,7 +1,7 @@
1
1
  import type { ActionStatus } from "../action.js";
2
2
  import type { BaseActionBus } from "../actionBus.js";
3
3
  import type { KeyOf } from "../lib/types.js";
4
- import type { AsyncActionState } from "./useAsyncAction.js";
4
+ import { type AsyncActionState } from "./useAsyncAction.js";
5
5
  export type { ActionStatus, AsyncActionState };
6
6
  /**
7
7
  * Subscribes to the status of a named action on an ActionBus and returns
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useSyncExternalStore } from "react";
2
+ import { toAsyncActionState } from "./useAsyncAction.js";
2
3
  /**
3
4
  * Subscribes to the status of a named action on an ActionBus and returns
4
5
  * `{ loading, error, response }` for driving `loading`/`disabled` UI. This is
@@ -18,9 +19,5 @@ export function useActionBusStatus(bus, name) {
18
19
  }, [bus, name]);
19
20
  const getSnapshot = useCallback(() => bus.getStatus(name), [bus, name]);
20
21
  const status = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
21
- return {
22
- loading: status.pending,
23
- error: status.error,
24
- response: status.response,
25
- };
22
+ return toAsyncActionState(status);
26
23
  }
@@ -6,6 +6,12 @@ export type AsyncActionState<Response = any> = {
6
6
  error: Error | null;
7
7
  response: Response | null;
8
8
  };
9
+ /**
10
+ * Project an action's {@link ActionStatus} into the {@link AsyncActionState}
11
+ * shape consumed by the status hooks (`pending` -> `loading`). Shared so
12
+ * `useAsyncAction` and `useActionBusStatus` stay in lockstep.
13
+ */
14
+ export declare function toAsyncActionState<Response>(status: ActionStatus<Response>): AsyncActionState<Response>;
9
15
  /**
10
16
  * Wraps a function in an action and exposes its in-flight status, so a
11
17
  * component can drive `loading`/`disabled` without a hand-rolled
@@ -1,5 +1,17 @@
1
1
  import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from "react";
2
2
  import { createAction } from "../action.js";
3
+ /**
4
+ * Project an action's {@link ActionStatus} into the {@link AsyncActionState}
5
+ * shape consumed by the status hooks (`pending` -> `loading`). Shared so
6
+ * `useAsyncAction` and `useActionBusStatus` stay in lockstep.
7
+ */
8
+ export function toAsyncActionState(status) {
9
+ return {
10
+ loading: status.pending,
11
+ error: status.error,
12
+ response: status.response,
13
+ };
14
+ }
3
15
  /**
4
16
  * Wraps a function in an action and exposes its in-flight status, so a
5
17
  * component can drive `loading`/`disabled` without a hand-rolled
@@ -44,10 +56,6 @@ export function useAsyncAction(fn) {
44
56
  const invoke = useCallback((...args) => action.invoke(...args), [action]);
45
57
  return [
46
58
  invoke,
47
- {
48
- loading: status.pending,
49
- error: status.error,
50
- response: status.response,
51
- },
59
+ toAsyncActionState(status),
52
60
  ];
53
61
  }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef } from "react";
2
- import { areListenerOptionsEqual } from "./listenerOptionsEqual.js";
2
+ import { areListenerOptionsEqual, fillListenerUpdateDefaults, } from "./listenerOptionsEqual.js";
3
3
  /**
4
4
  * Reconciles a single reactive listener across renders without relying on the
5
5
  * identity of the options object.
@@ -37,7 +37,16 @@ export function useReconciledListener({ keyDeps, options, subscribe, unsubscribe
37
37
  return;
38
38
  }
39
39
  if (!areListenerOptionsEqual(committedRef.current, options)) {
40
- update(context, options);
40
+ // Normalize to a full soft-option set so a field dropped since the
41
+ // last render resets to its default: updateListenerOptions is
42
+ // partial-merge, but the React path is declarative (see
43
+ // fillListenerUpdateDefaults). Carry `signal` through only when
44
+ // present (matching the old whole-options pass), so its abort wiring
45
+ // is rebound/cleared exactly as before; `context` is passed
46
+ // separately and not read from the options object.
47
+ update(context, Object.assign(Object.assign({}, fillListenerUpdateDefaults(options)), ((options === null || options === void 0 ? void 0 : options.signal) !== undefined
48
+ ? { signal: options.signal }
49
+ : {})));
41
50
  }
42
51
  committedRef.current = options;
43
52
  });
@@ -122,6 +122,15 @@ export function useStoreSelector(store, arg2, arg3, arg4) {
122
122
  };
123
123
  }, [store, selector, equalityFn, deps, inst]);
124
124
  const subscribe = useCallback((onStoreChange) => {
125
+ // subscribe can run (during commit) for an already-destroyed store
126
+ // — e.g. a provider torn down before this component mounts, or a
127
+ // `store` change re-running subscribe. control() reaches the
128
+ // destroyed control bus's addListener, which throws "EventBus is
129
+ // destroyed" out of render. Skip subscribing and hand back a no-op
130
+ // cleanup; getSelection already reads destroyed stores safely.
131
+ if (store.isDestroyed()) {
132
+ return () => { };
133
+ }
125
134
  const listener = (names) => {
126
135
  const currentDeps = depsRef.current;
127
136
  if (currentDeps
@@ -1,6 +1,15 @@
1
1
  import { useCallback, useSyncExternalStore } from "react";
2
2
  export function useStoreState(store, key) {
3
3
  const subscribe = useCallback((onStoreChange) => {
4
+ // subscribe can run (during commit) for a store that was already
5
+ // destroyed — e.g. a provider torn down before this component
6
+ // mounts, or a `key`/`store` change re-running subscribe. onChange
7
+ // reaches the destroyed changes event's addListener, which throws
8
+ // "Event is destroyed" out of render. Skip subscribing and hand back
9
+ // a no-op cleanup; getSnapshot already reads destroyed stores safely.
10
+ if (store.isDestroyed()) {
11
+ return () => { };
12
+ }
4
13
  const listener = () => {
5
14
  onStoreChange();
6
15
  };
package/dist/store.js CHANGED
@@ -80,13 +80,20 @@ export function createStore(initialData = {}) {
80
80
  }
81
81
  return true;
82
82
  };
83
- const _set = (name, value, triggerChange = true) => {
83
+ const _set = (name, value, triggerChange = true, runBeforeChange = true) => {
84
84
  var _a, _b, _c, _d, _e;
85
85
  const prev = data.get(name);
86
86
  if (prev !== value) {
87
- const beforeChangeResults = control.all(BeforeChangeEventName, name, value);
88
- if (beforeChangeResults.some((result) => result === false)) {
89
- return;
87
+ // A computed recompute skips the beforeChange veto (runBeforeChange
88
+ // false): the initial computed seed() bypasses beforeChange for the
89
+ // same reason, so allowing a veto here would leave the derived value
90
+ // stale and no longer equal to fn(deps) — internally inconsistent
91
+ // with the seeded value. beforeChange still gates ordinary sets.
92
+ if (runBeforeChange) {
93
+ const beforeChangeResults = control.all(BeforeChangeEventName, name, value);
94
+ if (beforeChangeResults.some((result) => result === false)) {
95
+ return;
96
+ }
90
97
  }
91
98
  const pipeArgs = [value];
92
99
  let newValue;
@@ -199,6 +206,22 @@ export function createStore(initialData = {}) {
199
206
  return false;
200
207
  };
201
208
  function asyncSet(name, value) {
209
+ // Validate computed keys synchronously, mirroring set(). Deferring the
210
+ // check to the timer callback would turn a misuse into an uncaught
211
+ // exception escaping the timer (crashing Node / surfacing as an uncaught
212
+ // browser error) instead of a catchable throw at the call site.
213
+ if (typeof name === "string") {
214
+ if (computedKeys.has(name)) {
215
+ throw new Error(`Cannot set computed property "${name}"`);
216
+ }
217
+ }
218
+ else if (typeof name === "object" && name !== null) {
219
+ for (const k of Object.keys(name)) {
220
+ if (computedKeys.has(k)) {
221
+ throw new Error(`Cannot set computed property "${k}"`);
222
+ }
223
+ }
224
+ }
202
225
  const timer = setTimeout(() => {
203
226
  pendingTimers.delete(timer);
204
227
  // The store may have been destroyed between scheduling and firing.
@@ -214,12 +237,14 @@ export function createStore(initialData = {}) {
214
237
  }, 0);
215
238
  pendingTimers.add(timer);
216
239
  }
217
- // Replay a coalesced change log: one onChange per key, keeping the first
218
- // entry's pre-cascade `prev` and the last entry's settled `value`, dropping
219
- // keys whose net value is unchanged. Mirrors batch()'s replay (including its
220
- // store-change error routing) so a computed touched several times during a
221
- // cascade emits a single, internally-consistent onChange.
222
- const replayCoalescedChanges = (log, hasCallbackError, callbackError) => {
240
+ // Coalesce a change log per key and replay it as one onChange each, keeping
241
+ // the first entry's pre-cascade `prev` and the last entry's settled `value`
242
+ // and dropping keys whose net value is unchanged. Store-change errors route
243
+ // to the error event; an unhandled one propagates unless the surrounding
244
+ // callback already failed (`hasCallbackError`), in which case it is swallowed
245
+ // so the original callback error is the one that ultimately surfaces. Shared
246
+ // by batch() and the single-set cascade wrapper so both coalesce identically.
247
+ const replayCoalescedLog = (log, hasCallbackError) => {
223
248
  var _a;
224
249
  const coalesced = new Map();
225
250
  for (const [propName, value, prev] of log) {
@@ -260,6 +285,11 @@ export function createStore(initialData = {}) {
260
285
  throw error;
261
286
  }
262
287
  }
288
+ };
289
+ // Replay a coalesced cascade log and, if the driving callback failed, rethrow
290
+ // its error last (after every surviving onChange has fired).
291
+ const replayCoalescedChanges = (log, hasCallbackError, callbackError) => {
292
+ replayCoalescedLog(log, hasCallbackError);
263
293
  if (hasCallbackError) {
264
294
  throw callbackError;
265
295
  }
@@ -461,7 +491,7 @@ export function createStore(initialData = {}) {
461
491
  };
462
492
  let batching = false;
463
493
  const batch = (fn) => {
464
- var _a, _b;
494
+ var _a;
465
495
  if (batching) {
466
496
  throw new Error("Nested batch() calls are not supported");
467
497
  }
@@ -498,48 +528,10 @@ export function createStore(initialData = {}) {
498
528
  // Coalesce the log per key before replaying: a key written multiple
499
529
  // times in the batch (e.g. a computed recomputing once per base-key
500
530
  // write) must fire onChange once with its final value, not once per
501
- // intermediate value. Keep first-occurrence order, the pre-batch `prev`
502
- // from the first entry, and the final `value` from the last entry; drop
503
- // keys whose net value is unchanged from before the batch.
504
- const coalesced = new Map();
505
- for (const [propName, value, prev] of log) {
506
- const existing = coalesced.get(propName);
507
- if (existing) {
508
- existing.value = value;
509
- }
510
- else {
511
- coalesced.set(propName, { value, prev });
512
- }
513
- }
514
- for (const [propName, { value, prev }] of coalesced) {
515
- if (value === prev) {
516
- continue;
517
- }
518
- const changeArgs = [
519
- value,
520
- prev,
521
- ];
522
- try {
523
- changes.trigger(propName, ...changeArgs);
524
- }
525
- catch (error) {
526
- control.trigger(ErrorEventName, {
527
- error: error instanceof Error
528
- ? error
529
- : new Error(String(error)),
530
- args: changeArgs,
531
- type: "store-change",
532
- name: propName,
533
- });
534
- if ((_a = control.get(ErrorEventName)) === null || _a === void 0 ? void 0 : _a.hasListener()) {
535
- continue;
536
- }
537
- if (hasCallbackError) {
538
- continue;
539
- }
540
- throw error;
541
- }
542
- }
531
+ // intermediate value. Drop keys whose net value is unchanged from before
532
+ // the batch. The callback error (if any) is deferred and rethrown at the
533
+ // end so the partial-write control change event below still fires.
534
+ replayCoalescedLog(log, hasCallbackError);
543
535
  // Dedupe so the control change event lists each key once, matching the
544
536
  // non-batch path (which dedupes via `dedupe([name, ...effectKeys])`).
545
537
  // A computed touched by several base-key writes would otherwise repeat.
@@ -556,7 +548,7 @@ export function createStore(initialData = {}) {
556
548
  args: [dedupedChangedKeys],
557
549
  type: "store-control",
558
550
  });
559
- if ((_b = control.get(ErrorEventName)) === null || _b === void 0 ? void 0 : _b.hasListener()) {
551
+ if ((_a = control.get(ErrorEventName)) === null || _a === void 0 ? void 0 : _a.hasListener()) {
560
552
  if (hasCallbackError) {
561
553
  throw callbackError;
562
554
  }
@@ -670,7 +662,10 @@ export function createStore(initialData = {}) {
670
662
  }
671
663
  computingKeys.add(key);
672
664
  try {
673
- _set(key, fn(...depValues));
665
+ // runBeforeChange=false: a computed key is derived and must
666
+ // always hold fn(deps); a beforeChange veto here would strand a
667
+ // stale value (see _set and the seed() rationale).
668
+ _set(key, fn(...depValues), true, false);
674
669
  }
675
670
  finally {
676
671
  computingKeys.delete(key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuindji/reactive",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "author": "Ivan Kuindzhi",
5
5
  "type": "module",
6
6
  "repository": {