@kuindji/reactive 1.1.0 → 1.2.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/README.md +128 -5
- package/dist/action.d.ts +19 -0
- package/dist/action.js +137 -14
- package/dist/actionBus.d.ts +5 -0
- package/dist/actionBus.js +168 -4
- package/dist/actionMap.d.ts +5 -0
- package/dist/event.d.ts +34 -1
- package/dist/event.js +256 -43
- package/dist/eventBus.d.ts +2 -0
- package/dist/eventBus.js +106 -3
- package/dist/lib/types.d.ts +1 -1
- package/dist/react/useActionBusStatus.d.ts +13 -0
- package/dist/react/useActionBusStatus.js +26 -0
- package/dist/react/useAsyncAction.d.ts +20 -0
- package/dist/react/useAsyncAction.js +53 -0
- package/dist/react/useStoreSelector.d.ts +35 -0
- package/dist/react/useStoreSelector.js +144 -0
- package/dist/react/useStoreState.js +13 -1
- package/dist/react.d.ts +3 -0
- package/dist/react.js +3 -0
- package/dist/store.d.ts +3 -0
- package/dist/store.js +410 -43
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@ A JavaScript/TypeScript utility library for building reactive applications with
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
11
|
- **Event System**: Event emitter with subscriber/dispatcher and collector modes
|
|
12
|
-
- **Action System**: Async action handling with error management and response
|
|
13
|
-
- **Store System**: Reactive state management with change tracking and
|
|
12
|
+
- **Action System**: Async action handling with error management, response tracking and loading/error/response status
|
|
13
|
+
- **Store System**: Reactive state management with change tracking, validation and computed/derived values
|
|
14
14
|
- **EventBus**: Centralized event management for complex applications
|
|
15
15
|
- **ActionBus & ActionMap**: Organized action management with error handling
|
|
16
16
|
- **React Integration**: Full React hooks support with error boundaries
|
|
@@ -99,9 +99,21 @@ event.addListener(handler, {
|
|
|
99
99
|
tags: string[], // Listener tags for filtering; default undefined
|
|
100
100
|
async: booleantrue, // Call this listener asynchronously; default false
|
|
101
101
|
extraData: object, // Custom data will be passed to filter()
|
|
102
|
+
signal: AbortSignal, // Auto-remove the listener when this signal aborts
|
|
102
103
|
});
|
|
103
104
|
```
|
|
104
105
|
|
|
106
|
+
When a `signal` is provided, the listener is removed automatically once the
|
|
107
|
+
signal aborts (and is not added at all if the signal is already aborted). The
|
|
108
|
+
abort subscription is cleaned up if the listener is removed first, so there is no
|
|
109
|
+
dangling reference into a still-live signal:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
event.addListener(handler, { signal: controller.signal });
|
|
114
|
+
controller.abort(); // handler is now removed
|
|
115
|
+
```
|
|
116
|
+
|
|
105
117
|
### Collector
|
|
106
118
|
|
|
107
119
|
Collector allows you to gather data from listeners.
|
|
@@ -159,9 +171,10 @@ const value = event.pipe(1); // value = 4
|
|
|
159
171
|
|
|
160
172
|
- `addListener(listener, options?)` - Add event listener
|
|
161
173
|
- **Aliases**: `on()`, `listen()`, `subscribe()`
|
|
174
|
+
- `once(listener, options?)` - Add a listener that is removed after a single call (sugar for `addListener(listener, { ...options, limit: 1 })`)
|
|
162
175
|
- `removeListener(listener, context?, tag?)` - Remove specific listener
|
|
163
176
|
- **Aliases**: `un()`, `off()`, `remove()`, `unsubscribe()`
|
|
164
|
-
- `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.
|
|
177
|
+
- `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.
|
|
165
178
|
- `hasListener(listener?, context?, tag?)` - Check if listener exists
|
|
166
179
|
- **Aliases**: `has()`
|
|
167
180
|
- `removeAllListeners(tag?)` - Remove all listeners (optionally by tag)
|
|
@@ -197,8 +210,17 @@ const value = event.pipe(1); // value = 4
|
|
|
197
210
|
- `suspend(withQueue?: boolean)` - Suspend event triggering; When `withQueue=true`, all trigger calls will be queued and replayed after resume()
|
|
198
211
|
- `resume()` - Resume event triggering
|
|
199
212
|
- `reset()` - Reset event state
|
|
213
|
+
- `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.
|
|
214
|
+
- `isDestroyed()` - Returns `true` once `destroy()` has been called
|
|
200
215
|
- `withTags(tags: string[], callback: () => CallbackResponse) => CallbackResponse` - Execute callback with specific tags
|
|
201
216
|
|
|
217
|
+
#### Introspection
|
|
218
|
+
|
|
219
|
+
- `listenerCount(tag?)` - Number of registered listeners, optionally filtered by tag
|
|
220
|
+
- `triggeredCount()` - How many times the event has been triggered
|
|
221
|
+
- `lastTriggerArgs()` - The most recent trigger arguments (a copy), or `null` if never triggered
|
|
222
|
+
- `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.
|
|
223
|
+
|
|
202
224
|
## EventBus
|
|
203
225
|
|
|
204
226
|
### Description
|
|
@@ -522,7 +544,9 @@ customSource.trigger("appStart");
|
|
|
522
544
|
- `removeEventSource(source)` - Remove event source
|
|
523
545
|
- `suspendAll(withQueue?)` - Suspend all events
|
|
524
546
|
- `resumeAll()` - Resume all events
|
|
525
|
-
- `reset()` - Reset all events
|
|
547
|
+
- `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.
|
|
548
|
+
- `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.
|
|
549
|
+
- `isDestroyed()` - Returns `true` once `destroy()` has been called
|
|
526
550
|
- `withTags(tags, callback)` - Execute callback with specific tags
|
|
527
551
|
|
|
528
552
|
## Action
|
|
@@ -571,6 +595,8 @@ const result = await fetchUserAction.invoke("user123");
|
|
|
571
595
|
- **Aliases**: `un()`, `off()`, `remove()`, `unsubscribe()`
|
|
572
596
|
- `updateListenerOptions(handler, context?, nextOptions?)` - Update a response listener's soft options in place (see Event's `updateListenerOptions`)
|
|
573
597
|
- `removeAllListeners(tag?)` - Remove all listeners
|
|
598
|
+
- `destroy()` - Tear down the action: destroy its response, before-action, error and status events and mark it dead. After `destroy()`, `invoke()`/`addListener()` throw.
|
|
599
|
+
- `isDestroyed()` - Returns `true` once `destroy()` has been called
|
|
574
600
|
|
|
575
601
|
#### Error Handling
|
|
576
602
|
|
|
@@ -578,6 +604,30 @@ const result = await fetchUserAction.invoke("user123");
|
|
|
578
604
|
- `removeErrorListener(handler, context?)` - Remove error listener
|
|
579
605
|
- `removeAllErrorListeners(tag?)` - Remove all error listeners
|
|
580
606
|
|
|
607
|
+
#### Status (loading / error / response)
|
|
608
|
+
|
|
609
|
+
An action tracks the status of its `invoke` lifecycle so UI can drive
|
|
610
|
+
`loading`/`disabled` without a hand-rolled `useState(false)`. `pending` is true
|
|
611
|
+
while one or more invocations are in flight; `response`/`error` hold the last
|
|
612
|
+
settled outcome (a before-action veto settles to neither). This is **not** a
|
|
613
|
+
cache — `response` is just the last value.
|
|
614
|
+
|
|
615
|
+
- `getStatus()` - Returns `{ pending: boolean, error: Error | null, response: T | null }`. The reference is stable while unchanged (safe for `useSyncExternalStore`).
|
|
616
|
+
- `onStatusChange(handler)` - Subscribe to status changes
|
|
617
|
+
- `removeStatusListener(handler)` - Remove a status listener
|
|
618
|
+
|
|
619
|
+
```typescript
|
|
620
|
+
const saveAction = createAction(async (data: FormData) => save(data));
|
|
621
|
+
|
|
622
|
+
saveAction.onStatusChange(({ pending, error }) => {
|
|
623
|
+
button.disabled = pending;
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
await saveAction.invoke(form); // pending -> true, then false on settle
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
In React, prefer the `useAsyncAction` / `useActionBusStatus` hooks (see React Hooks).
|
|
630
|
+
|
|
581
631
|
#### Utility Methods
|
|
582
632
|
|
|
583
633
|
- `promise(options?)` - Get promise for next invocation
|
|
@@ -703,6 +753,17 @@ const user = await actionBus.invoke("fetchUser", "user123");
|
|
|
703
753
|
- `removeListener(name, handler, context?, tag?)` - Remove listener
|
|
704
754
|
- **Aliases**: `un()`, `off()`, `remove()`, `unsubscribe()`
|
|
705
755
|
- `updateListenerOptions(name, handler, context?, nextOptions?)` - Update a response listener's soft options in place (see Event's `updateListenerOptions`)
|
|
756
|
+
- `destroy()` - Tear down the bus: destroy every owned action and the error event, then drop them all. After `destroy()`, `invoke()`/`on()` throw.
|
|
757
|
+
- `isDestroyed()` - Returns `true` once `destroy()` has been called
|
|
758
|
+
|
|
759
|
+
#### Status (loading / error / response)
|
|
760
|
+
|
|
761
|
+
Delegates to the underlying action's status (see Action → Status). This is the
|
|
762
|
+
primary path for apps that route mutations through one shared ActionBus.
|
|
763
|
+
|
|
764
|
+
- `getStatus(name)` - Status for a named action; an unregistered name reports an idle status
|
|
765
|
+
- `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)
|
|
766
|
+
- `removeStatusListener(name, handler)` - Remove a status listener (also clears a subscription retained before registration)
|
|
706
767
|
|
|
707
768
|
#### Error Handling
|
|
708
769
|
|
|
@@ -761,9 +822,12 @@ const userData = userStore.get([ "name", "email" ]); // { name: string, email: s
|
|
|
761
822
|
- `asyncSet(data)` - Async set multiple properties
|
|
762
823
|
- `get(key)` - Get single property
|
|
763
824
|
- `get(keys)` - Get multiple properties
|
|
825
|
+
- `computed(key, deps, fn)` - Register a derived value (see Computed values)
|
|
764
826
|
- `isEmpty()` - Check if store is empty
|
|
765
827
|
- `getData()` - Get all store data
|
|
766
|
-
- `reset()` - Clear store data
|
|
828
|
+
- `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.
|
|
829
|
+
- `destroy()` - Tear down the store: destroy the underlying change/pipe/control buses and drop all data. After `destroy()`, `set()`/`get()` throw.
|
|
830
|
+
- `isDestroyed()` - Returns `true` once `destroy()` has been called
|
|
767
831
|
|
|
768
832
|
#### Event Methods
|
|
769
833
|
|
|
@@ -784,6 +848,40 @@ const userData = userStore.get([ "name", "email" ]); // { name: string, email: s
|
|
|
784
848
|
|
|
785
849
|
- `batch(fn)` - Batch multiple changes
|
|
786
850
|
|
|
851
|
+
#### Computed values
|
|
852
|
+
|
|
853
|
+
Declare a derived key in the store type, then attach its derivation with
|
|
854
|
+
`computed(key, deps, fn)`. It recomputes automatically when any dependency
|
|
855
|
+
changes and notifies like any other key — `get`, `getData`, `onChange`,
|
|
856
|
+
`useStoreState` and `useStoreSelector` all see it transparently. Computed keys
|
|
857
|
+
are read-only: calling `set` on one throws. Computed-of-computed chains are
|
|
858
|
+
supported, and a cyclic computed throws rather than looping.
|
|
859
|
+
|
|
860
|
+
```typescript
|
|
861
|
+
type UserStore = {
|
|
862
|
+
first: string;
|
|
863
|
+
last: string;
|
|
864
|
+
fullName: string; // declared in the type, registered as computed
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
const store = createStore<UserStore>({ first: "Jane", last: "Doe" });
|
|
868
|
+
|
|
869
|
+
store.computed("fullName", [ "first", "last" ], (first, last) => `${first} ${last}`);
|
|
870
|
+
|
|
871
|
+
store.get("fullName"); // "Jane Doe"
|
|
872
|
+
store.onChange("fullName", (v) => console.log(v));
|
|
873
|
+
store.set("first", "John"); // fullName recomputes -> "John Doe"
|
|
874
|
+
store.set("fullName", "x"); // throws: computed is read-only
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
> **Note:** recompute is registration-order, not topologically sorted, so a
|
|
878
|
+
> chained or diamond-shaped computed may recompute internally more than once per
|
|
879
|
+
> change. This is invisible to consumers: a single `set(...)`/`set({...})`/`batch`
|
|
880
|
+
> coalesces the `onChange` stream, so each computed fires `onChange` once with
|
|
881
|
+
> its settled value and the correct previous value. The final value is always
|
|
882
|
+
> correct. Registering base computeds before dependents reduces redundant
|
|
883
|
+
> internal recomputes.
|
|
884
|
+
|
|
787
885
|
## React Hooks
|
|
788
886
|
|
|
789
887
|
### Description
|
|
@@ -965,6 +1063,31 @@ useListenToStoreChanges(
|
|
|
965
1063
|
)
|
|
966
1064
|
```
|
|
967
1065
|
|
|
1066
|
+
Select a derived slice with equality (bails out of re-renders while the result
|
|
1067
|
+
is unchanged). Two forms — a selector over the whole state, or a deps-keyed form
|
|
1068
|
+
that recomputes only when the listed keys change:
|
|
1069
|
+
|
|
1070
|
+
```typescript
|
|
1071
|
+
// selector form (default equality is Object.is)
|
|
1072
|
+
const label = useStoreSelector(store, (s) => `${s.first} ${s.last}`, shallowEqual?);
|
|
1073
|
+
|
|
1074
|
+
// deps-keyed form
|
|
1075
|
+
const anyLoading = useStoreSelector(store, [ "a", "b", "c" ], (a, b, c) => a || b || c);
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
Drive `loading`/`disabled` from an action's status. `useActionBusStatus` is the
|
|
1079
|
+
primary path for apps built around one shared ActionBus; `useAsyncAction` wraps a
|
|
1080
|
+
standalone function:
|
|
1081
|
+
|
|
1082
|
+
```typescript
|
|
1083
|
+
// shared ActionBus
|
|
1084
|
+
const { loading, error, response } = useActionBusStatus(appActions, "user/login");
|
|
1085
|
+
|
|
1086
|
+
// standalone action
|
|
1087
|
+
const [ submit, { loading, error } ] = useAsyncAction(saveProfileFn);
|
|
1088
|
+
// <Button loading={loading} disabled={loading} onClick={() => submit(form)} />
|
|
1089
|
+
```
|
|
1090
|
+
|
|
968
1091
|
### Reconciliation across renders
|
|
969
1092
|
|
|
970
1093
|
Hook inputs are reconciled on every render using semantic comparison, so you
|
package/dist/action.d.ts
CHANGED
|
@@ -9,6 +9,18 @@ export type ActionResponse<Response = any, Args extends unknown[] = unknown[]> =
|
|
|
9
9
|
args: Args;
|
|
10
10
|
};
|
|
11
11
|
export type ListenerSignature<ActionSignature extends BaseHandler> = (arg: ActionResponse<Awaited<ReturnType<ActionSignature>>, Parameters<ActionSignature>>) => void;
|
|
12
|
+
/**
|
|
13
|
+
* Status of an action's `invoke` lifecycle, suitable for driving
|
|
14
|
+
* `loading`/`disabled` UI. `pending` is true while one or more invocations are
|
|
15
|
+
* in flight; `response`/`error` hold the last settled outcome (a before-veto
|
|
16
|
+
* settles to neither). This is not a cache — `response` is just the last value.
|
|
17
|
+
*/
|
|
18
|
+
export type ActionStatus<Response = any> = {
|
|
19
|
+
pending: boolean;
|
|
20
|
+
error: Error | null;
|
|
21
|
+
response: Response | null;
|
|
22
|
+
};
|
|
23
|
+
export type StatusListenerSignature<ActionSignature extends BaseHandler> = (status: ActionStatus<Awaited<ReturnType<ActionSignature>>>) => void;
|
|
12
24
|
export type BeforeActionSignature<ActionSignature extends BaseHandler> = (...args: Parameters<ActionSignature>) => false | void | Promise<false | void>;
|
|
13
25
|
export type ActionDefinitionHelper<A extends BaseHandler> = {
|
|
14
26
|
actionSignature: A;
|
|
@@ -21,10 +33,17 @@ export type ActionDefinitionHelper<A extends BaseHandler> = {
|
|
|
21
33
|
beforeActionSignature: BeforeActionSignature<A>;
|
|
22
34
|
errorListenerArgument: ErrorResponse<Parameters<A>>;
|
|
23
35
|
errorListenerSignature: ErrorListenerSignature<Parameters<A>>;
|
|
36
|
+
statusType: ActionStatus<Awaited<ReturnType<A>>>;
|
|
37
|
+
statusListenerSignature: StatusListenerSignature<A>;
|
|
24
38
|
};
|
|
25
39
|
export declare function createAction<A extends BaseHandler>(action: A): ApiType<ActionDefinitionHelper<A>, {
|
|
26
40
|
readonly invoke: (...args: Parameters<A>) => Promise<ActionResponse<Awaited<ReturnType<A>>, Parameters<A>>>;
|
|
27
41
|
readonly setAction: (nextAction: A) => void;
|
|
42
|
+
readonly destroy: () => void;
|
|
43
|
+
readonly isDestroyed: () => boolean;
|
|
44
|
+
readonly getStatus: () => ActionStatus<Awaited<ReturnType<A>>>;
|
|
45
|
+
readonly onStatusChange: (handler: StatusListenerSignature<A>, listenerOptions?: import("./event.js").ListenerOptions) => void;
|
|
46
|
+
readonly removeStatusListener: (handler: StatusListenerSignature<A>, context?: object | null, tag?: string | null) => boolean;
|
|
28
47
|
readonly addListener: (handler: ListenerSignature<A>, listenerOptions?: import("./event.js").ListenerOptions) => void;
|
|
29
48
|
/** @alias addListener */
|
|
30
49
|
readonly on: (handler: ListenerSignature<A>, listenerOptions?: import("./event.js").ListenerOptions) => void;
|
package/dist/action.js
CHANGED
|
@@ -14,10 +14,86 @@ export function createAction(action) {
|
|
|
14
14
|
// place via setAction without disturbing any listeners (response, before
|
|
15
15
|
// and error listeners live in separate events independent of the function).
|
|
16
16
|
let actionFn = action;
|
|
17
|
-
const { trigger, addListener, removeAllListeners, removeListener, updateListenerOptions, promise, } = createEvent();
|
|
18
|
-
const { all: triggerBeforeAction, addListener: addBeforeActionListener, removeAllListeners: removeAllBeforeActionListeners, removeListener: removeBeforeActionListener, promise: beforeActionPromise, } = createEvent();
|
|
19
|
-
const { trigger: triggerError, addListener: addErrorListener, removeAllListeners: removeAllErrorListeners, removeListener: removeErrorListener, promise: errorPromise, hasListener: hasErrorListeners, } = createEvent();
|
|
17
|
+
const { trigger, addListener, removeAllListeners, removeListener, updateListenerOptions, promise, destroy: destroyResponseEvent, } = createEvent();
|
|
18
|
+
const { all: triggerBeforeAction, addListener: addBeforeActionListener, removeAllListeners: removeAllBeforeActionListeners, removeListener: removeBeforeActionListener, promise: beforeActionPromise, destroy: destroyBeforeEvent, } = createEvent();
|
|
19
|
+
const { trigger: triggerError, addListener: addErrorListener, removeAllListeners: removeAllErrorListeners, removeListener: removeErrorListener, promise: errorPromise, hasListener: hasErrorListeners, destroy: destroyErrorEvent, } = createEvent();
|
|
20
|
+
// Status is a side channel over the invoke lifecycle: a dedicated event so
|
|
21
|
+
// a React hook can subscribe through useSyncExternalStore. The status
|
|
22
|
+
// object reference is kept stable and only rebuilt when a field actually
|
|
23
|
+
// changes, which is required for useSyncExternalStore to bail out of
|
|
24
|
+
// redundant renders.
|
|
25
|
+
const { trigger: triggerStatus, addListener: addStatusListener, removeListener: removeStatusListener, destroy: destroyStatusEvent, } = createEvent();
|
|
26
|
+
let destroyed = false;
|
|
27
|
+
let inFlight = 0;
|
|
28
|
+
let lastResponse = null;
|
|
29
|
+
let lastError = null;
|
|
30
|
+
// Frozen so getStatus() can hand out the live reference (required for
|
|
31
|
+
// useSyncExternalStore to bail out of redundant renders) without a consumer
|
|
32
|
+
// being able to mutate it — a tampered `pending` would make updateStatus()
|
|
33
|
+
// believe nothing changed and suppress the next notification.
|
|
34
|
+
let currentStatus = Object.freeze({
|
|
35
|
+
pending: false,
|
|
36
|
+
error: null,
|
|
37
|
+
response: null,
|
|
38
|
+
});
|
|
39
|
+
const updateStatus = () => {
|
|
40
|
+
const pending = inFlight > 0;
|
|
41
|
+
if (currentStatus.pending === pending
|
|
42
|
+
&& currentStatus.error === lastError
|
|
43
|
+
&& currentStatus.response === lastResponse) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
currentStatus = Object.freeze({
|
|
47
|
+
pending,
|
|
48
|
+
error: lastError,
|
|
49
|
+
response: lastResponse,
|
|
50
|
+
});
|
|
51
|
+
// The status event may have been torn down while an invocation was in
|
|
52
|
+
// flight. Still reconcile `currentStatus` above (so getStatus() does not
|
|
53
|
+
// strand `pending: true` after a mid-flight destroy), but skip emitting
|
|
54
|
+
// onto the dead event, which would throw and mask the real outcome.
|
|
55
|
+
if (destroyed) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Status is a side channel. A throwing status listener must not corrupt
|
|
59
|
+
// the invoke lifecycle: if it propagated here it would, depending on the
|
|
60
|
+
// call site, abort execution or skip the inFlight decrement and strand
|
|
61
|
+
// `pending: true`. Isolate it and surface it via the error event.
|
|
62
|
+
try {
|
|
63
|
+
triggerStatus(currentStatus);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
// Surface the failure via the error event, but a throwing error
|
|
67
|
+
// listener must not re-escape either: this runs before invoke()
|
|
68
|
+
// enters its try/finally, so any escape would strand pending:true
|
|
69
|
+
// and skip execution entirely.
|
|
70
|
+
try {
|
|
71
|
+
triggerError({
|
|
72
|
+
error: error instanceof Error
|
|
73
|
+
? error
|
|
74
|
+
: new Error(String(error)),
|
|
75
|
+
args: [],
|
|
76
|
+
type: "action-status",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (_a) {
|
|
80
|
+
// Nothing left to route to; swallow to protect the lifecycle.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const getStatus = () => currentStatus;
|
|
20
85
|
const invoke = (...args) => __awaiter(this, void 0, void 0, function* () {
|
|
86
|
+
if (destroyed) {
|
|
87
|
+
throw new Error("Action is destroyed");
|
|
88
|
+
}
|
|
89
|
+
// Snapshot whether error listeners existed at invocation start. The
|
|
90
|
+
// catch below ORs this with a live re-check: the snapshot guards against
|
|
91
|
+
// destroy() tearing listeners down mid-flight (which must not flip a
|
|
92
|
+
// handled failure into a rejection), while the live check still routes
|
|
93
|
+
// the error to a listener registered after invoke() began.
|
|
94
|
+
const handlesErrors = hasErrorListeners();
|
|
95
|
+
inFlight++;
|
|
96
|
+
updateStatus();
|
|
21
97
|
try {
|
|
22
98
|
const beforeResponse = triggerBeforeAction(...args);
|
|
23
99
|
const beforeResults = isPromiseLike(beforeResponse)
|
|
@@ -25,12 +101,22 @@ export function createAction(action) {
|
|
|
25
101
|
: beforeResponse;
|
|
26
102
|
for (const before of beforeResults) {
|
|
27
103
|
if (before === false) {
|
|
104
|
+
// A before-veto is a no-op for the caller, not a settlement:
|
|
105
|
+
// leave lastResponse/lastError untouched so a vetoed
|
|
106
|
+
// invocation cannot wipe the status of a concurrent (or
|
|
107
|
+
// prior) real invocation. A fresh action still reads idle
|
|
108
|
+
// because both start null.
|
|
28
109
|
const response = {
|
|
29
110
|
response: null,
|
|
30
111
|
error: "Action cancelled",
|
|
31
112
|
args: args,
|
|
32
113
|
};
|
|
33
|
-
|
|
114
|
+
// Skip emitting if destroyed mid-flight: the caller still
|
|
115
|
+
// gets its settled response, but the torn-down event is not
|
|
116
|
+
// triggered (which would throw "Event is destroyed").
|
|
117
|
+
if (!destroyed) {
|
|
118
|
+
trigger(response);
|
|
119
|
+
}
|
|
34
120
|
return response;
|
|
35
121
|
}
|
|
36
122
|
}
|
|
@@ -38,16 +124,33 @@ export function createAction(action) {
|
|
|
38
124
|
if (isPromiseLike(result)) {
|
|
39
125
|
result = yield Promise.resolve(result);
|
|
40
126
|
}
|
|
127
|
+
lastResponse = result;
|
|
128
|
+
lastError = null;
|
|
41
129
|
const response = {
|
|
42
130
|
response: result,
|
|
43
131
|
error: null,
|
|
44
132
|
args: args,
|
|
45
133
|
};
|
|
46
|
-
|
|
134
|
+
// A successful invocation must still resolve with its result even if
|
|
135
|
+
// the action was destroyed while awaiting; only skip the emit.
|
|
136
|
+
if (!destroyed) {
|
|
137
|
+
trigger(response);
|
|
138
|
+
}
|
|
47
139
|
return response;
|
|
48
140
|
}
|
|
49
141
|
catch (error) {
|
|
50
|
-
|
|
142
|
+
// Record the failure before the re-throw branch so status is
|
|
143
|
+
// correct even when invoke re-throws (no error listener).
|
|
144
|
+
lastError = error instanceof Error
|
|
145
|
+
? error
|
|
146
|
+
: new Error(error);
|
|
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()) {
|
|
51
154
|
throw error;
|
|
52
155
|
}
|
|
53
156
|
const response = {
|
|
@@ -55,23 +158,43 @@ export function createAction(action) {
|
|
|
55
158
|
error: error instanceof Error ? error.message : error,
|
|
56
159
|
args: args,
|
|
57
160
|
};
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
161
|
+
if (!destroyed) {
|
|
162
|
+
trigger(response);
|
|
163
|
+
triggerError({
|
|
164
|
+
error: lastError,
|
|
165
|
+
args: args,
|
|
166
|
+
type: "action",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
66
169
|
return response;
|
|
67
170
|
}
|
|
171
|
+
finally {
|
|
172
|
+
inFlight--;
|
|
173
|
+
updateStatus();
|
|
174
|
+
}
|
|
68
175
|
});
|
|
69
176
|
const setAction = (nextAction) => {
|
|
70
177
|
actionFn = nextAction;
|
|
71
178
|
};
|
|
179
|
+
// One-call teardown: destroy the underlying response/before/error/status
|
|
180
|
+
// events and mark the action dead. Post-destroy invoke/addListener throw
|
|
181
|
+
// rather than silently no-op.
|
|
182
|
+
const destroy = () => {
|
|
183
|
+
destroyResponseEvent();
|
|
184
|
+
destroyBeforeEvent();
|
|
185
|
+
destroyErrorEvent();
|
|
186
|
+
destroyStatusEvent();
|
|
187
|
+
destroyed = true;
|
|
188
|
+
};
|
|
189
|
+
const isDestroyed = () => destroyed;
|
|
72
190
|
const api = {
|
|
73
191
|
invoke,
|
|
74
192
|
setAction,
|
|
193
|
+
destroy,
|
|
194
|
+
isDestroyed,
|
|
195
|
+
getStatus,
|
|
196
|
+
onStatusChange: addStatusListener,
|
|
197
|
+
removeStatusListener,
|
|
75
198
|
addListener,
|
|
76
199
|
/** @alias addListener */
|
|
77
200
|
on: addListener,
|
package/dist/actionBus.d.ts
CHANGED
|
@@ -21,6 +21,11 @@ export declare function createActionBus<ActionsMap extends BaseActionsMap>(initi
|
|
|
21
21
|
readonly has: (name: MapKey) => boolean;
|
|
22
22
|
readonly get: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K) => GetActionTypesMap<ActionsMap>[K];
|
|
23
23
|
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]>>>;
|
|
24
|
+
readonly destroy: () => void;
|
|
25
|
+
readonly isDestroyed: () => boolean;
|
|
26
|
+
readonly getStatus: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K) => GetActionDefinitionsMap<ActionsMap>[K]["statusType"];
|
|
27
|
+
readonly onStatusChange: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, handler: GetActionDefinitionsMap<ActionsMap>[K]["statusListenerSignature"]) => void;
|
|
28
|
+
readonly removeStatusListener: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, handler: GetActionDefinitionsMap<ActionsMap>[K]["statusListenerSignature"]) => boolean | undefined;
|
|
24
29
|
readonly addListener: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, handler: GetActionDefinitionsMap<ActionsMap>[K]["listenerSignature"], options?: ListenerOptions) => void;
|
|
25
30
|
/** @alias addListener */
|
|
26
31
|
readonly on: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, handler: GetActionDefinitionsMap<ActionsMap>[K]["listenerSignature"], options?: ListenerOptions) => void;
|