@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 +9 -6
- package/dist/react/listenerOptionsEqual.d.ts +14 -0
- package/dist/react/listenerOptionsEqual.js +26 -0
- package/dist/react/useActionBusStatus.d.ts +1 -1
- package/dist/react/useActionBusStatus.js +2 -5
- package/dist/react/useAsyncAction.d.ts +6 -0
- package/dist/react/useAsyncAction.js +13 -5
- package/dist/react/useReconciledListener.js +11 -2
- package/dist/react/useStoreSelector.js +9 -0
- package/dist/react/useStoreState.js +9 -0
- package/dist/store.js +50 -55
- package/package.json +1 -1
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
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
//
|
|
218
|
-
// entry's pre-cascade `prev` and the last entry's settled `value
|
|
219
|
-
// keys whose net value is unchanged.
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
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
|
|
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.
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
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 ((
|
|
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
|
-
|
|
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);
|