@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/dist/actionBus.js CHANGED
@@ -3,16 +3,44 @@ import { createEvent } from "./event.js";
3
3
  export function createActionBus(initialActions = {}, errorListener) {
4
4
  const actions = new Map();
5
5
  const errorEvent = createEvent();
6
+ let destroyed = false;
7
+ // The error-forwarding listener attached to each action's error event is
8
+ // retained per name so removeAction can detach it. Otherwise a held action
9
+ // reference keeps forwarding errors into the bus after removal, and — since
10
+ // the forwarder counts as an error listener — its invoke resolves with an
11
+ // error response instead of rejecting.
12
+ const errorForwarders = new Map();
13
+ // Status subscriptions for actions that are not registered yet. They are
14
+ // recorded here so a later add()/replace() can attach them — otherwise a
15
+ // hook subscribing before registration (e.g. useActionBusStatus) would stay
16
+ // unsubscribed forever. Kept after attach so a re-added action reattaches.
17
+ const pendingStatusListeners = new Map();
6
18
  if (errorListener) {
7
19
  errorEvent.addListener(errorListener);
8
20
  }
9
21
  const add = (name, action) => {
22
+ if (destroyed) {
23
+ throw new Error("ActionBus is destroyed");
24
+ }
10
25
  if (!actions.has(name)) {
11
26
  const a = createAction(action);
12
- a.addErrorListener(({ error, args }) => {
13
- errorEvent.emit({ name, error, args, type: "action" });
14
- });
27
+ // Preserve the original error type (e.g. "action-status") rather
28
+ // than hardcoding "action", so consumers can distinguish a failed
29
+ // invocation from a throwing status listener.
30
+ const forwarder = ({ error, args, type }) => {
31
+ errorEvent.emit({ name, error, args, type });
32
+ };
33
+ errorForwarders.set(name, forwarder);
34
+ a.addErrorListener(forwarder);
15
35
  actions.set(name, a);
36
+ // Attach any status subscriptions that were registered before this
37
+ // action existed.
38
+ const pending = pendingStatusListeners.get(name);
39
+ if (pending) {
40
+ pending.forEach((handler) => {
41
+ a.onStatusChange(handler);
42
+ });
43
+ }
16
44
  }
17
45
  };
18
46
  Object.entries(initialActions).forEach(([name, action]) => {
@@ -28,6 +56,9 @@ export function createActionBus(initialActions = {}, errorListener) {
28
56
  // its listeners and the bus error-forwarding listener, which is attached to
29
57
  // the action's error event, not the function); otherwise add it.
30
58
  const replace = (name, action) => {
59
+ if (destroyed) {
60
+ throw new Error("ActionBus is destroyed");
61
+ }
31
62
  const existing = actions.get(name);
32
63
  if (existing) {
33
64
  existing.setAction(action);
@@ -40,9 +71,60 @@ export function createActionBus(initialActions = {}, errorListener) {
40
71
  // removeListener. The removed action's listeners and its error-forwarding
41
72
  // listener are dropped with it (they lived on the action's own events).
42
73
  const removeAction = (name) => {
43
- actions.delete(name);
74
+ const action = actions.get(name);
75
+ const existed = actions.delete(name);
76
+ // Detach the bus error-forwarding listener from the removed action so a
77
+ // held reference no longer feeds the bus error event (and so its invoke
78
+ // rejects rather than resolving via the forwarder acting as an error
79
+ // listener). A later re-add() installs a fresh forwarder.
80
+ const forwarder = errorForwarders.get(name);
81
+ if (forwarder) {
82
+ action === null || action === void 0 ? void 0 : action.removeErrorListener(forwarder);
83
+ errorForwarders.delete(name);
84
+ }
85
+ // getStatus() now reports idle for this name, but status subscribers
86
+ // (e.g. useActionBusStatus via useSyncExternalStore) were attached to
87
+ // the removed action's own status event and will never be notified of
88
+ // the drop. Detach each retained subscription from the removed action
89
+ // (otherwise invoking a held action reference keeps notifying a
90
+ // listener that bus.removeStatusListener can no longer reach), then push
91
+ // an idle status so they re-read and clear stale state. Subscriptions
92
+ // stay in pendingStatusListeners so a later re-add() reattaches them.
93
+ if (existed) {
94
+ const pending = pendingStatusListeners.get(name);
95
+ pending === null || pending === void 0 ? void 0 : pending.forEach((handler) => {
96
+ action === null || action === void 0 ? void 0 : action.removeStatusListener(handler);
97
+ // Isolate each notify: one throwing subscriber must not abort
98
+ // the loop and leave the remaining subscribers attached and
99
+ // un-notified. Route the failure to the bus error event.
100
+ try {
101
+ handler(idleStatus);
102
+ }
103
+ catch (error) {
104
+ // The error forwarding must not re-escape either: a throwing
105
+ // bus error listener would abort the loop and leave later
106
+ // subscribers attached and un-notified.
107
+ try {
108
+ errorEvent.emit({
109
+ name,
110
+ error: error instanceof Error
111
+ ? error
112
+ : new Error(String(error)),
113
+ args: [],
114
+ type: "action-status",
115
+ });
116
+ }
117
+ catch (_a) {
118
+ // Nothing left to route to; swallow to keep the loop going.
119
+ }
120
+ }
121
+ });
122
+ }
44
123
  };
45
124
  const invoke = (name, ...args) => {
125
+ if (destroyed) {
126
+ throw new Error("ActionBus is destroyed");
127
+ }
46
128
  const action = get(name);
47
129
  if (!action) {
48
130
  throw new Error(`Action ${name} not found`);
@@ -50,6 +132,9 @@ export function createActionBus(initialActions = {}, errorListener) {
50
132
  return action.invoke(...args);
51
133
  };
52
134
  const on = (name, handler, options) => {
135
+ if (destroyed) {
136
+ throw new Error("ActionBus is destroyed");
137
+ }
53
138
  const action = get(name);
54
139
  if (!action) {
55
140
  throw new Error(`Action ${name} not found`);
@@ -57,6 +142,9 @@ export function createActionBus(initialActions = {}, errorListener) {
57
142
  return action.addListener(handler, options);
58
143
  };
59
144
  const once = (name, handler, options) => {
145
+ if (destroyed) {
146
+ throw new Error("ActionBus is destroyed");
147
+ }
60
148
  options = options || {};
61
149
  options.limit = 1;
62
150
  const action = get(name);
@@ -66,6 +154,9 @@ export function createActionBus(initialActions = {}, errorListener) {
66
154
  return action.addListener(handler, options);
67
155
  };
68
156
  const un = (name, handler, context, tag) => {
157
+ if (destroyed) {
158
+ throw new Error("ActionBus is destroyed");
159
+ }
69
160
  const action = get(name);
70
161
  if (!action) {
71
162
  throw new Error(`Action ${name} not found`);
@@ -73,12 +164,80 @@ export function createActionBus(initialActions = {}, errorListener) {
73
164
  return action.removeListener(handler, context, tag);
74
165
  };
75
166
  const updateListenerOptions = (name, handler, context, nextOptions) => {
167
+ if (destroyed) {
168
+ throw new Error("ActionBus is destroyed");
169
+ }
76
170
  const action = get(name);
77
171
  if (!action) {
78
172
  return false;
79
173
  }
80
174
  return action.updateListenerOptions(handler, context, nextOptions);
81
175
  };
176
+ // Frozen and shared: getStatus() hands this single object to every missing
177
+ // action, so a consumer mutating it would corrupt all future idle snapshots
178
+ // (e.g. leaving a hook reporting false loading). Matches action status,
179
+ // whose snapshots are also frozen.
180
+ const idleStatus = Object.freeze({
181
+ pending: false,
182
+ error: null,
183
+ response: null,
184
+ });
185
+ // Status lives on the underlying action (the single in-flight point);
186
+ // the bus just delegates per name. An unregistered name reports idle and
187
+ // is a no-op to (un)subscribe.
188
+ const getStatus = (name) => {
189
+ const action = get(name);
190
+ if (!action) {
191
+ return idleStatus;
192
+ }
193
+ return action.getStatus();
194
+ };
195
+ const onStatusChange = (name, handler) => {
196
+ if (destroyed) {
197
+ throw new Error("ActionBus is destroyed");
198
+ }
199
+ // Record the subscription so it survives (re)registration, then attach
200
+ // to the action now if it already exists.
201
+ let pending = pendingStatusListeners.get(name);
202
+ if (!pending) {
203
+ pending = new Set();
204
+ pendingStatusListeners.set(name, pending);
205
+ }
206
+ pending.add(handler);
207
+ const action = get(name);
208
+ if (!action) {
209
+ return;
210
+ }
211
+ return action.onStatusChange(handler);
212
+ };
213
+ const removeStatusListener = (name, handler) => {
214
+ const pending = pendingStatusListeners.get(name);
215
+ if (pending) {
216
+ pending.delete(handler);
217
+ if (pending.size === 0) {
218
+ pendingStatusListeners.delete(name);
219
+ }
220
+ }
221
+ const action = get(name);
222
+ if (!action) {
223
+ return;
224
+ }
225
+ return action.removeStatusListener(handler);
226
+ };
227
+ // One-call teardown: destroy each owned action and the error event, then
228
+ // drop them all. Post-destroy invoke/addListener throw rather than silently
229
+ // no-op.
230
+ const destroy = () => {
231
+ actions.forEach((action) => {
232
+ action.destroy();
233
+ });
234
+ actions.clear();
235
+ pendingStatusListeners.clear();
236
+ errorForwarders.clear();
237
+ errorEvent.destroy();
238
+ destroyed = true;
239
+ };
240
+ const isDestroyed = () => destroyed;
82
241
  const api = {
83
242
  add,
84
243
  replace,
@@ -86,6 +245,11 @@ export function createActionBus(initialActions = {}, errorListener) {
86
245
  has,
87
246
  get,
88
247
  invoke,
248
+ destroy,
249
+ isDestroyed,
250
+ getStatus,
251
+ onStatusChange,
252
+ removeStatusListener,
89
253
  addListener: on,
90
254
  /** @alias addListener */
91
255
  on,
@@ -5,6 +5,11 @@ export type { ActionResponse, BaseActionsMap, ErrorListenerSignature, ErrorRespo
5
5
  export declare function createActionMap<M extends BaseActionsMap>(actions: M, onAnyError?: ErrorListenerSignature<any[]> | ErrorListenerSignature<any[]>[]): { [key in KeyOf<M>]: {
6
6
  readonly invoke: (...args: Parameters<M[key]>) => Promise<ActionResponse<Awaited<ReturnType<M[key]>>, Parameters<M[key]>>>;
7
7
  readonly setAction: (nextAction: M[key]) => void;
8
+ readonly destroy: () => void;
9
+ readonly isDestroyed: () => boolean;
10
+ readonly getStatus: () => import("./action.js").ActionStatus<Awaited<ReturnType<M[key]>>>;
11
+ readonly onStatusChange: (handler: import("./action.js").StatusListenerSignature<M[key]>, listenerOptions?: import("./event.js").ListenerOptions) => void;
12
+ readonly removeStatusListener: (handler: import("./action.js").StatusListenerSignature<M[key]>, context?: object | null, tag?: string | null) => boolean;
8
13
  readonly addListener: (handler: import("./action.js").ListenerSignature<M[key]>, listenerOptions?: import("./event.js").ListenerOptions) => void;
9
14
  readonly on: (handler: import("./action.js").ListenerSignature<M[key]>, listenerOptions?: import("./event.js").ListenerOptions) => void;
10
15
  readonly subscribe: (handler: import("./action.js").ListenerSignature<M[key]>, listenerOptions?: import("./event.js").ListenerOptions) => void;
package/dist/event.d.ts CHANGED
@@ -44,13 +44,39 @@ export interface ListenerOptions extends BaseOptions {
44
44
  * You can pass any additional fields here. They will be passed back to TriggerFilter
45
45
  */
46
46
  extraData?: any;
47
+ /**
48
+ * When provided, the listener is auto-removed once the signal aborts. If the
49
+ * signal is already aborted the listener is not added at all.
50
+ */
51
+ signal?: AbortSignal | null;
47
52
  }
48
- interface ListenerPrototype<Handler extends BaseHandler> extends Required<ListenerOptions> {
53
+ interface ListenerPrototype<Handler extends BaseHandler> extends Required<Omit<ListenerOptions, "signal">> {
49
54
  handler: Handler;
50
55
  called: number;
51
56
  count: number;
52
57
  index: number;
53
58
  start: number;
59
+ abortCleanup: (() => void) | null;
60
+ }
61
+ /**
62
+ * Read-only projection of a registered listener, returned by `getListeners()`.
63
+ * Carries the listener's options plus its live `called`/`count` counters but
64
+ * none of the mutable internals — mutating this object does not affect the
65
+ * event.
66
+ */
67
+ export interface ListenerInfo<Handler extends BaseHandler = BaseHandler> {
68
+ handler: Handler;
69
+ context: object | null;
70
+ tags: string[];
71
+ limit: number;
72
+ start: number;
73
+ called: number;
74
+ count: number;
75
+ async: boolean | number | null;
76
+ first: boolean;
77
+ alwaysFirst: boolean;
78
+ alwaysLast: boolean;
79
+ extraData: any;
54
80
  }
55
81
  export interface EventOptions<ListenerSignature extends BaseHandler> extends BaseOptions {
56
82
  /**
@@ -92,6 +118,7 @@ export declare function createEvent<ListenerSignature extends BaseHandler>(event
92
118
  readonly listen: (handler: ListenerSignature, listenerOptions?: ListenerOptions) => void;
93
119
  /** @alias addListener */
94
120
  readonly subscribe: (handler: ListenerSignature, listenerOptions?: ListenerOptions) => void;
121
+ readonly once: (handler: ListenerSignature, listenerOptions?: ListenerOptions) => void;
95
122
  readonly removeListener: (handler: ListenerSignature, context?: object | null, tag?: string | null) => boolean;
96
123
  readonly updateListenerOptions: (handler: ListenerSignature, context?: object | null, nextOptions?: ListenerOptions) => boolean;
97
124
  /** @alias removeListener */
@@ -117,8 +144,14 @@ export declare function createEvent<ListenerSignature extends BaseHandler>(event
117
144
  readonly resume: () => void;
118
145
  readonly setOptions: (eventOptions: Partial<EventOptions<ListenerSignature>>) => void;
119
146
  readonly reset: () => void;
147
+ readonly destroy: () => void;
148
+ readonly isDestroyed: () => boolean;
120
149
  readonly isSuspended: () => boolean;
121
150
  readonly isQueued: () => boolean;
151
+ readonly listenerCount: (tag?: string | null) => number;
152
+ readonly triggeredCount: () => number;
153
+ readonly lastTriggerArgs: () => Parameters<ListenerSignature> | null;
154
+ readonly getListeners: () => ListenerInfo<ListenerSignature>[];
122
155
  readonly withTags: <R>(tags: string[], callback: () => R) => R;
123
156
  readonly promise: (options?: ListenerOptions) => Promise<Parameters<ListenerSignature>>;
124
157
  readonly first: (...args: Parameters<ListenerSignature>) => ReturnType<ListenerSignature> | undefined;