@kuindji/reactive 1.0.24 → 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.
Files changed (60) hide show
  1. package/README.md +160 -14
  2. package/dist/action.d.ts +31 -10
  3. package/dist/action.js +156 -23
  4. package/dist/actionBus.d.ts +13 -4
  5. package/dist/actionBus.js +201 -5
  6. package/dist/actionMap.d.ts +26 -19
  7. package/dist/actionMap.js +10 -4
  8. package/dist/event.d.ts +37 -3
  9. package/dist/event.js +345 -78
  10. package/dist/eventBus.d.ts +7 -3
  11. package/dist/eventBus.js +194 -34
  12. package/dist/index.d.ts +7 -7
  13. package/dist/index.js +7 -7
  14. package/dist/lib/actionMapInternal.d.ts +8 -0
  15. package/dist/lib/actionMapInternal.js +8 -0
  16. package/dist/lib/isPromiseLike.d.ts +1 -0
  17. package/dist/lib/isPromiseLike.js +5 -0
  18. package/dist/lib/normalizeEventOptions.d.ts +13 -0
  19. package/dist/lib/normalizeEventOptions.js +21 -0
  20. package/dist/lib/types.d.ts +1 -1
  21. package/dist/react/ErrorBoundary.d.ts +1 -1
  22. package/dist/react/listenerOptionsEqual.d.ts +27 -0
  23. package/dist/react/listenerOptionsEqual.js +121 -0
  24. package/dist/react/useAction.d.ts +3 -3
  25. package/dist/react/useAction.js +10 -7
  26. package/dist/react/useActionBus.d.ts +4 -4
  27. package/dist/react/useActionBus.js +32 -2
  28. package/dist/react/useActionBusStatus.d.ts +13 -0
  29. package/dist/react/useActionBusStatus.js +26 -0
  30. package/dist/react/useActionMap.d.ts +4 -4
  31. package/dist/react/useActionMap.js +40 -7
  32. package/dist/react/useAsyncAction.d.ts +20 -0
  33. package/dist/react/useAsyncAction.js +53 -0
  34. package/dist/react/useEvent.d.ts +2 -2
  35. package/dist/react/useEvent.js +18 -2
  36. package/dist/react/useEventBus.d.ts +2 -2
  37. package/dist/react/useEventBus.js +14 -10
  38. package/dist/react/useListenToAction.d.ts +1 -1
  39. package/dist/react/useListenToAction.js +17 -38
  40. package/dist/react/useListenToActionBus.d.ts +3 -3
  41. package/dist/react/useListenToActionBus.js +15 -9
  42. package/dist/react/useListenToEvent.d.ts +2 -2
  43. package/dist/react/useListenToEvent.js +8 -6
  44. package/dist/react/useListenToEventBus.d.ts +3 -3
  45. package/dist/react/useListenToEventBus.js +9 -7
  46. package/dist/react/useListenToStoreChanges.d.ts +3 -3
  47. package/dist/react/useListenToStoreChanges.js +9 -7
  48. package/dist/react/useReconciledListener.d.ts +33 -0
  49. package/dist/react/useReconciledListener.js +44 -0
  50. package/dist/react/useStore.d.ts +2 -2
  51. package/dist/react/useStore.js +71 -19
  52. package/dist/react/useStoreSelector.d.ts +35 -0
  53. package/dist/react/useStoreSelector.js +144 -0
  54. package/dist/react/useStoreState.d.ts +2 -2
  55. package/dist/react/useStoreState.js +26 -21
  56. package/dist/react.d.ts +16 -13
  57. package/dist/react.js +16 -13
  58. package/dist/store.d.ts +12 -8
  59. package/dist/store.js +473 -39
  60. package/package.json +13 -3
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 tracking
13
- - **Store System**: Reactive state management with change tracking and validation
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,13 +171,16 @@ 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()`
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.
164
178
  - `hasListener(listener?, context?, tag?)` - Check if listener exists
165
179
  - **Aliases**: `has()`
166
180
  - `removeAllListeners(tag?)` - Remove all listeners (optionally by tag)
167
181
  - `trigger(...args)` - Trigger the event
168
182
  - **Aliases**: `emit()`, `dispatch()`
183
+ - `setOptions(options)` - Update event options in place. Accepts any `EventOptions` field (`async`, `limit`, `autoTrigger`, `filter`, `filterContext`, `maxListeners`). Does not reset the internal `triggered` count.
169
184
 
170
185
  #### Collector Methods
171
186
 
@@ -195,8 +210,17 @@ const value = event.pipe(1); // value = 4
195
210
  - `suspend(withQueue?: boolean)` - Suspend event triggering; When `withQueue=true`, all trigger calls will be queued and replayed after resume()
196
211
  - `resume()` - Resume event triggering
197
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
198
215
  - `withTags(tags: string[], callback: () => CallbackResponse) => CallbackResponse` - Execute callback with specific tags
199
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
+
200
224
  ## EventBus
201
225
 
202
226
  ### Description
@@ -481,10 +505,12 @@ customSource.trigger("appStart");
481
505
  - `once(name, handler, options?)` - Add one-time listener
482
506
  - `removeListener(name, handler, context?, tag?)` - Remove listener
483
507
  - **Aliases**: `un()`, `off()`, `remove()`, `unsubscribe()`
508
+ - `updateListenerOptions(name, handler, context?, nextOptions?)` - Update a registered listener's soft options in place (see Event's `updateListenerOptions`). Returns `false` if the event does not exist.
484
509
  - `trigger(name, ...args)` - Trigger specific event
485
510
  - **Aliases**: `emit()`, `dispatch()`
486
511
  - `get(name)` - Get event instance by name
487
512
  - `add(name, options?)` - Add new event to bus
513
+ - `setOptions(options?)` - Update bus options. Present per-event entries in `eventOptions` are applied to already-created events via `event.setOptions`, and future events use the latest stored options. A removed event-name entry leaves the existing event unchanged.
488
514
 
489
515
  #### Collector Methods
490
516
 
@@ -518,7 +544,9 @@ customSource.trigger("appStart");
518
544
  - `removeEventSource(source)` - Remove event source
519
545
  - `suspendAll(withQueue?)` - Suspend all events
520
546
  - `resumeAll()` - Resume all events
521
- - `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
522
550
  - `withTags(tags, callback)` - Execute callback with specific tags
523
551
 
524
552
  ## Action
@@ -560,27 +588,57 @@ const result = await fetchUserAction.invoke("user123");
560
588
  #### Core Methods
561
589
 
562
590
  - `invoke(...args)` - Execute the action
591
+ - `setAction(fn)` - Replace the action function in place. Subsequent `invoke()` calls use the new function; all response, before-action and error listeners are preserved (they live in separate events). The replacement must keep a compatible signature.
563
592
  - `addListener(handler, options?)` - Add response listener
564
593
  - **Aliases**: `on()`, `listen()`, `subscribe()`
565
594
  - `removeListener(handler, context?, tag?)` - Remove listener
566
595
  - **Aliases**: `un()`, `off()`, `remove()`, `unsubscribe()`
567
- - `hasListener(handler?, context?, tag?)` - Check if listener exists
568
- - **Aliases**: `has()`
596
+ - `updateListenerOptions(handler, context?, nextOptions?)` - Update a response listener's soft options in place (see Event's `updateListenerOptions`)
569
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
570
600
 
571
601
  #### Error Handling
572
602
 
573
603
  - `addErrorListener(handler, context?)` - Add error listener
574
604
  - `removeErrorListener(handler, context?)` - Remove error listener
575
- - `hasErrorListeners()` - Check if error listeners exist
576
605
  - `removeAllErrorListeners(tag?)` - Remove all error listeners
577
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
+
578
631
  #### Utility Methods
579
632
 
580
633
  - `promise(options?)` - Get promise for next invocation
581
- - `suspend(withQueue?)` - Suspend action execution
582
- - `resume()` - Resume action execution
583
- - `reset()` - Reset action state
634
+ - `beforeActionPromise(options?)` - Get promise for the next before-action call
635
+ - `errorPromise(options?)` - Get promise for the next action error
636
+
637
+ #### Before Action Methods
638
+
639
+ - `addBeforeActionListener(handler, options?)` - Add listener that runs before invocation
640
+ - `removeBeforeActionListener(handler, context?, tag?)` - Remove before-action listener
641
+ - `removeAllBeforeActionListeners(tag?)` - Remove all before-action listeners
584
642
 
585
643
  ## ActionMap
586
644
 
@@ -683,7 +741,10 @@ const user = await actionBus.invoke("fetchUser", "user123");
683
741
 
684
742
  #### Core Methods
685
743
 
686
- - `add(name, action)` - Add action to bus
744
+ - `add(name, action)` - Add action to bus (no-op if it already exists)
745
+ - `replace(name, action)` - Replace an existing action's function in place via `setAction` (preserving its listeners and the bus error-forwarding listener); adds it if the name is new
746
+ - `removeAction(name)` - Remove an action from the bus (named `removeAction` because `remove` is an alias for `removeListener`). Afterwards `invoke`/`on`/`un` for that name throw `Action <name> not found`.
747
+ - `has(name)` - Check if action exists
687
748
  - `get(name)` - Get action by name
688
749
  - `invoke(name, ...args)` - Invoke action by name
689
750
  - `addListener(name, handler, options?)` - Add listener to action
@@ -691,8 +752,18 @@ const user = await actionBus.invoke("fetchUser", "user123");
691
752
  - `once(name, handler, options?)` - Add one-time listener
692
753
  - `removeListener(name, handler, context?, tag?)` - Remove listener
693
754
  - **Aliases**: `un()`, `off()`, `remove()`, `unsubscribe()`
694
- - `has(name)` - Check if action exists
695
- - `remove(name)` - Remove action
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)
696
767
 
697
768
  #### Error Handling
698
769
 
@@ -751,13 +822,18 @@ const userData = userStore.get([ "name", "email" ]); // { name: string, email: s
751
822
  - `asyncSet(data)` - Async set multiple properties
752
823
  - `get(key)` - Get single property
753
824
  - `get(keys)` - Get multiple properties
825
+ - `computed(key, deps, fn)` - Register a derived value (see Computed values)
754
826
  - `isEmpty()` - Check if store is empty
755
827
  - `getData()` - Get all store data
756
- - `reset()` - Reset store to initial state
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
757
831
 
758
832
  #### Event Methods
759
833
 
760
- - `onChange(key, handler)` - Listen to property changes
834
+ - `onChange(key, handler, options?)` - Listen to property changes
835
+ - `removeOnChange(key, handler, context?, tag?)` - Remove a change listener
836
+ - `updateOnChangeOptions(key, handler, context?, nextOptions?)` - Update a change listener's soft options in place (see Event's `updateListenerOptions`)
761
837
  - `pipe(key, handler)` - Add data transformation pipeline
762
838
  - `control(event, handler)` - Control store events
763
839
 
@@ -772,6 +848,40 @@ const userData = userStore.get([ "name", "email" ]); // { name: string, email: s
772
848
 
773
849
  - `batch(fn)` - Batch multiple changes
774
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
+
775
885
  ## React Hooks
776
886
 
777
887
  ### Description
@@ -953,6 +1063,42 @@ useListenToStoreChanges(
953
1063
  )
954
1064
  ```
955
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
+
1091
+ ### Reconciliation across renders
1092
+
1093
+ Hook inputs are reconciled on every render using semantic comparison, so you
1094
+ can safely pass inline objects (e.g. `{ tags: [tag] }`) without object identity
1095
+ forcing a resubscribe:
1096
+
1097
+ - **Listener options** (`useListenToEvent`/`useListenToEventBus`/`useListenToActionBus`/`useListenToStoreChanges`) are compared field by field (`tags` is an order-insensitive set). A semantically equal object is a no-op. A changed soft option updates the live listener **in place**, preserving its `called`/`count` counters. Changing `context` (an identity field) resubscribes using the old context.
1098
+ - **`useEvent` event options** and **`useEventBus` options** are reconciled via `setOptions` instead of being ignored (and `useEventBus` no longer throws when options change).
1099
+ - **`useAction`/`useActionBus`/`useActionMap` action functions** are replaced in place via `setAction` (compared by reference), preserving all listeners; `useActionBus` also adds/removes actions as its map changes. The `useActionMap` key set is fixed by its type contract — a runtime key-set change throws.
1100
+ - **`useStore` config** (`onChange`/`pipes`/`control`) is reconciled by category + key (functions compared by reference); only handlers added by the hook are touched. **`initialData` is seed-only** — it initializes the store once and later changes are intentionally ignored (live data is owned by `set`/`useStoreState`).
1101
+
956
1102
  ## ErrorBoundary
957
1103
 
958
1104
  ### Description
package/dist/action.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ApiType, BaseHandler, ErrorListenerSignature, ErrorResponse } from "./lib/types";
1
+ import type { ApiType, BaseHandler, ErrorListenerSignature, ErrorResponse } from "./lib/types.js";
2
2
  export type ActionResponse<Response = any, Args extends unknown[] = unknown[]> = {
3
3
  response: Response;
4
4
  error: null;
@@ -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,16 +33,24 @@ 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
- readonly addListener: (handler: ListenerSignature<A>, listenerOptions?: import("./event").ListenerOptions) => void;
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;
47
+ readonly addListener: (handler: ListenerSignature<A>, listenerOptions?: import("./event.js").ListenerOptions) => void;
28
48
  /** @alias addListener */
29
- readonly on: (handler: ListenerSignature<A>, listenerOptions?: import("./event").ListenerOptions) => void;
49
+ readonly on: (handler: ListenerSignature<A>, listenerOptions?: import("./event.js").ListenerOptions) => void;
30
50
  /** @alias addListener */
31
- readonly subscribe: (handler: ListenerSignature<A>, listenerOptions?: import("./event").ListenerOptions) => void;
51
+ readonly subscribe: (handler: ListenerSignature<A>, listenerOptions?: import("./event.js").ListenerOptions) => void;
32
52
  /** @alias addListener */
33
- readonly listen: (handler: ListenerSignature<A>, listenerOptions?: import("./event").ListenerOptions) => void;
53
+ readonly listen: (handler: ListenerSignature<A>, listenerOptions?: import("./event.js").ListenerOptions) => void;
34
54
  readonly removeAllListeners: (tag?: string) => void;
35
55
  readonly removeListener: (handler: ListenerSignature<A>, context?: object | null, tag?: string | null) => boolean;
36
56
  /** @alias removeListener */
@@ -41,15 +61,16 @@ export declare function createAction<A extends BaseHandler>(action: A): ApiType<
41
61
  readonly remove: (handler: ListenerSignature<A>, context?: object | null, tag?: string | null) => boolean;
42
62
  /** @alias removeListener */
43
63
  readonly unsubscribe: (handler: ListenerSignature<A>, context?: object | null, tag?: string | null) => boolean;
44
- readonly promise: (options?: import("./event").ListenerOptions) => Promise<[arg: ActionResponse<Awaited<ReturnType<A>>, Parameters<A>>]>;
45
- readonly addErrorListener: (handler: ErrorListenerSignature<Parameters<A>>, listenerOptions?: import("./event").ListenerOptions) => void;
64
+ readonly updateListenerOptions: (handler: ListenerSignature<A>, context?: object | null, nextOptions?: import("./event.js").ListenerOptions) => boolean;
65
+ readonly promise: (options?: import("./event.js").ListenerOptions) => Promise<[arg: ActionResponse<Awaited<ReturnType<A>>, Parameters<A>>]>;
66
+ readonly addErrorListener: (handler: ErrorListenerSignature<Parameters<A>>, listenerOptions?: import("./event.js").ListenerOptions) => void;
46
67
  readonly removeAllErrorListeners: (tag?: string) => void;
47
68
  readonly removeErrorListener: (handler: ErrorListenerSignature<Parameters<A>>, context?: object | null, tag?: string | null) => boolean;
48
- readonly errorPromise: (options?: import("./event").ListenerOptions) => Promise<[errorResponse: ErrorResponse<Parameters<A>>]>;
49
- readonly addBeforeActionListener: (handler: BeforeActionSignature<A>, listenerOptions?: import("./event").ListenerOptions) => void;
69
+ readonly errorPromise: (options?: import("./event.js").ListenerOptions) => Promise<[errorResponse: ErrorResponse<Parameters<A>>]>;
70
+ readonly addBeforeActionListener: (handler: BeforeActionSignature<A>, listenerOptions?: import("./event.js").ListenerOptions) => void;
50
71
  readonly removeAllBeforeActionListeners: (tag?: string) => void;
51
72
  readonly removeBeforeActionListener: (handler: BeforeActionSignature<A>, context?: object | null, tag?: string | null) => boolean;
52
- readonly beforeActionPromise: (options?: import("./event").ListenerOptions) => Promise<Parameters<A>>;
73
+ readonly beforeActionPromise: (options?: import("./event.js").ListenerOptions) => Promise<Parameters<A>>;
53
74
  }>;
54
75
  export type BaseActionDefinition = ActionDefinitionHelper<(...args: [any]) => any>;
55
76
  export type BaseAction = ReturnType<typeof createAction<(...args: [any]) => any>>;
package/dist/action.js CHANGED
@@ -7,42 +7,150 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
- import { createEvent } from "./event";
10
+ import { createEvent } from "./event.js";
11
+ import isPromiseLike from "./lib/isPromiseLike.js";
11
12
  export function createAction(action) {
12
- const { trigger, addListener, removeAllListeners, removeListener, promise, } = createEvent();
13
- const { all: triggerBeforeAction, addListener: addBeforeActionListener, removeAllListeners: removeAllBeforeActionListeners, removeListener: removeBeforeActionListener, promise: beforeActionPromise, } = createEvent();
14
- const { trigger: triggerError, addListener: addErrorListener, removeAllListeners: removeAllErrorListeners, removeListener: removeErrorListener, promise: errorPromise, hasListener: hasErrorListeners, } = createEvent();
13
+ // The action function is held in a mutable variable so it can be swapped in
14
+ // place via setAction without disturbing any listeners (response, before
15
+ // and error listeners live in separate events independent of the function).
16
+ let actionFn = action;
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;
15
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();
16
97
  try {
17
- const beforeResults = triggerBeforeAction(...args);
18
- for (let before of beforeResults) {
19
- if (before instanceof Promise) {
20
- before = yield before;
21
- }
98
+ const beforeResponse = triggerBeforeAction(...args);
99
+ const beforeResults = isPromiseLike(beforeResponse)
100
+ ? yield Promise.resolve(beforeResponse)
101
+ : beforeResponse;
102
+ for (const before of beforeResults) {
22
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.
23
109
  const response = {
24
110
  response: null,
25
111
  error: "Action cancelled",
26
112
  args: args,
27
113
  };
28
- trigger(response);
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
+ }
29
120
  return response;
30
121
  }
31
122
  }
32
- let result = action(...args);
33
- if (result instanceof Promise) {
34
- result = yield result;
123
+ let result = actionFn(...args);
124
+ if (isPromiseLike(result)) {
125
+ result = yield Promise.resolve(result);
35
126
  }
127
+ lastResponse = result;
128
+ lastError = null;
36
129
  const response = {
37
130
  response: result,
38
131
  error: null,
39
132
  args: args,
40
133
  };
41
- trigger(response);
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
+ }
42
139
  return response;
43
140
  }
44
141
  catch (error) {
45
- if (!hasErrorListeners()) {
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()) {
46
154
  throw error;
47
155
  }
48
156
  const response = {
@@ -50,19 +158,43 @@ export function createAction(action) {
50
158
  error: error instanceof Error ? error.message : error,
51
159
  args: args,
52
160
  };
53
- trigger(response);
54
- triggerError({
55
- error: error instanceof Error
56
- ? error
57
- : new Error(error),
58
- args: args,
59
- type: "action",
60
- });
161
+ if (!destroyed) {
162
+ trigger(response);
163
+ triggerError({
164
+ error: lastError,
165
+ args: args,
166
+ type: "action",
167
+ });
168
+ }
61
169
  return response;
62
170
  }
171
+ finally {
172
+ inFlight--;
173
+ updateStatus();
174
+ }
63
175
  });
176
+ const setAction = (nextAction) => {
177
+ actionFn = nextAction;
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;
64
190
  const api = {
65
191
  invoke,
192
+ setAction,
193
+ destroy,
194
+ isDestroyed,
195
+ getStatus,
196
+ onStatusChange: addStatusListener,
197
+ removeStatusListener,
66
198
  addListener,
67
199
  /** @alias addListener */
68
200
  on: addListener,
@@ -80,6 +212,7 @@ export function createAction(action) {
80
212
  remove: removeListener,
81
213
  /** @alias removeListener */
82
214
  unsubscribe: removeListener,
215
+ updateListenerOptions,
83
216
  promise,
84
217
  addErrorListener,
85
218
  removeAllErrorListeners,
@@ -1,6 +1,6 @@
1
- import { ActionDefinitionHelper, createAction } from "./action";
2
- import { ListenerOptions } from "./event";
3
- import type { ApiType, BaseHandler, ErrorListenerSignature, KeyOf, MapKey } from "./lib/types";
1
+ import { ActionDefinitionHelper, createAction } from "./action.js";
2
+ import { ListenerOptions } from "./event.js";
3
+ import type { ApiType, BaseHandler, ErrorListenerSignature, KeyOf, MapKey } from "./lib/types.js";
4
4
  export interface BaseActionsMap {
5
5
  [key: MapKey]: BaseHandler;
6
6
  }
@@ -16,8 +16,16 @@ export type ActionBusDefinitionHelper<ActionsMap extends BaseActionsMap> = {
16
16
  };
17
17
  export declare function createActionBus<ActionsMap extends BaseActionsMap>(initialActions?: ActionsMap, errorListener?: ErrorListenerSignature<any[]>): ApiType<ActionBusDefinitionHelper<ActionsMap>, {
18
18
  readonly add: (name: MapKey, action: BaseHandler) => void;
19
+ readonly replace: (name: MapKey, action: BaseHandler) => void;
20
+ readonly removeAction: (name: MapKey) => void;
21
+ readonly has: (name: MapKey) => boolean;
19
22
  readonly get: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K) => GetActionTypesMap<ActionsMap>[K];
20
- readonly invoke: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, ...args: GetActionDefinitionsMap<ActionsMap>[K]["actionArguments"]) => Promise<import("./action").ActionResponse<Awaited<ReturnType<ActionsMap[K]>>, Parameters<ActionsMap[K]>>>;
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;
21
29
  readonly addListener: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, handler: GetActionDefinitionsMap<ActionsMap>[K]["listenerSignature"], options?: ListenerOptions) => void;
22
30
  /** @alias addListener */
23
31
  readonly on: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, handler: GetActionDefinitionsMap<ActionsMap>[K]["listenerSignature"], options?: ListenerOptions) => void;
@@ -35,6 +43,7 @@ export declare function createActionBus<ActionsMap extends BaseActionsMap>(initi
35
43
  readonly un: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, handler: GetActionDefinitionsMap<ActionsMap>[K]["listenerSignature"], context?: object | null, tag?: string | null) => boolean;
36
44
  /** @alias removeListener */
37
45
  readonly unsubscribe: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, handler: GetActionDefinitionsMap<ActionsMap>[K]["listenerSignature"], context?: object | null, tag?: string | null) => boolean;
46
+ readonly updateListenerOptions: <K extends KeyOf<GetActionDefinitionsMap<ActionsMap>>>(name: K, handler: GetActionDefinitionsMap<ActionsMap>[K]["listenerSignature"], context?: object | null, nextOptions?: ListenerOptions) => boolean;
38
47
  readonly addErrorListener: (handler: ErrorListenerSignature<any[]>, listenerOptions?: ListenerOptions) => void;
39
48
  readonly removeErrorListener: (handler: ErrorListenerSignature<any[]>, context?: object | null, tag?: string | null) => boolean;
40
49
  }>;