@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/event.js CHANGED
@@ -9,24 +9,37 @@ export function createEvent(eventOptions = {}) {
9
9
  let queue = [];
10
10
  let suspended = false;
11
11
  let queued = false;
12
+ let destroyed = false;
12
13
  let triggered = 0;
13
14
  let lastTrigger = null;
15
+ // The args replayed to a late autoTrigger listener. Recorded only while
16
+ // autoTrigger is enabled, so enabling it *after* a trigger does not replay
17
+ // that earlier (pre-enablement) trigger. Kept separate from `lastTrigger`,
18
+ // which is recorded on every trigger purely for `lastTriggerArgs`.
19
+ let autoTriggerArgs = null;
14
20
  let sortListeners = false;
15
21
  let currentTagsFilter = null;
16
22
  const options = Object.assign({ async: null, limit: null, autoTrigger: null, filter: null, filterContext: null, maxListeners: 0 }, eventOptions);
17
23
  const addListener = (handler, listenerOptions = {}) => {
18
- var _a;
24
+ var _a, _b;
25
+ if (destroyed) {
26
+ throw new Error("Event is destroyed");
27
+ }
19
28
  if (!handler) {
20
29
  return;
21
30
  }
22
- const listenerContext = (_a = listenerOptions.context) !== null && _a !== void 0 ? _a : null;
31
+ const signal = (_a = listenerOptions.signal) !== null && _a !== void 0 ? _a : null;
32
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
33
+ return;
34
+ }
35
+ const listenerContext = (_b = listenerOptions.context) !== null && _b !== void 0 ? _b : null;
23
36
  if (listeners.find((l) => l.handler === handler && l.context === listenerContext)) {
24
37
  return;
25
38
  }
26
39
  if (options.maxListeners && listeners.length >= options.maxListeners) {
27
40
  throw new Error(`Max listeners (${options.maxListeners}) reached`);
28
41
  }
29
- const listener = Object.assign({ handler, called: 0, count: 0, index: listeners.length, start: 1, context: null, tags: [], extraData: null, first: false, alwaysFirst: false, alwaysLast: false, limit: 0, async: null }, listenerOptions);
42
+ const listener = Object.assign(Object.assign({ handler, called: 0, count: 0, index: listeners.length, start: 1, context: null, tags: [], extraData: null, first: false, alwaysFirst: false, alwaysLast: false, limit: 0, async: null }, listenerOptions), { abortCleanup: null });
30
43
  if (listener.async === true) {
31
44
  listener.async = 1;
32
45
  }
@@ -47,25 +60,32 @@ export function createEvent(eventOptions = {}) {
47
60
  || (listenerOptions === null || listenerOptions === void 0 ? void 0 : listenerOptions.alwaysLast) === true) {
48
61
  sortListeners = true;
49
62
  }
63
+ if (signal) {
64
+ const onAbort = () => {
65
+ removeListener(handler, listenerContext);
66
+ };
67
+ signal.addEventListener("abort", onAbort, { once: true });
68
+ listener.abortCleanup = () => {
69
+ signal.removeEventListener("abort", onAbort);
70
+ };
71
+ }
50
72
  if (options.autoTrigger
51
- && lastTrigger !== null
73
+ && autoTriggerArgs !== null
52
74
  && !suspended) {
53
- const prevFilter = options.filter;
54
- options.filter = (args, l) => {
55
- if (l && l.handler === handler) {
56
- return prevFilter ? prevFilter(args, l) !== false : true;
57
- }
58
- return false;
59
- };
60
- try {
61
- _trigger(lastTrigger);
62
- }
63
- finally {
64
- options.filter = prevFilter;
65
- }
75
+ // Replay the last enabled trigger, but only into the listener just
76
+ // added. The replay target is passed explicitly to `_trigger` (not
77
+ // via shared closure/`options.filter` state) so that a real trigger
78
+ // fired synchronously by the replayed handler is an ordinary real
79
+ // trigger — full bookkeeping, limit enforcement, and delivery to all
80
+ // listeners — rather than inheriting this replay's suppression.
81
+ _trigger(autoTriggerArgs, null, null, {
82
+ handler,
83
+ context: listenerContext !== null && listenerContext !== void 0 ? listenerContext : null,
84
+ });
66
85
  }
67
86
  };
68
87
  const removeListener = (handler, context, tag) => {
88
+ var _a;
69
89
  const inx = listeners.findIndex((l) => {
70
90
  if (l.handler !== handler) {
71
91
  return false;
@@ -84,11 +104,12 @@ export function createEvent(eventOptions = {}) {
84
104
  if (inx === -1) {
85
105
  return false;
86
106
  }
87
- listeners.splice(inx, 1);
107
+ const [removed] = listeners.splice(inx, 1);
108
+ (_a = removed === null || removed === void 0 ? void 0 : removed.abortCleanup) === null || _a === void 0 ? void 0 : _a.call(removed);
88
109
  return true;
89
110
  };
90
111
  const updateListenerOptions = (handler, context = null, nextOptions = {}) => {
91
- var _a, _b, _c, _d, _e, _f, _g;
112
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
92
113
  const listenerContext = context !== null && context !== void 0 ? context : null;
93
114
  const listener = listeners.find((l) => l.handler === handler && l.context === listenerContext);
94
115
  if (!listener) {
@@ -96,19 +117,35 @@ export function createEvent(eventOptions = {}) {
96
117
  }
97
118
  const prevAlwaysFirst = listener.alwaysFirst;
98
119
  const prevAlwaysLast = listener.alwaysLast;
99
- // Soft fields, applying the same defaults as addListener so that a
100
- // removed field resets to its default rather than lingering.
101
- listener.limit = (_a = nextOptions.limit) !== null && _a !== void 0 ? _a : 0;
102
- listener.start = (_b = nextOptions.start) !== null && _b !== void 0 ? _b : 1;
103
- listener.tags = (_c = nextOptions.tags) !== null && _c !== void 0 ? _c : [];
104
- listener.extraData = (_d = nextOptions.extraData) !== null && _d !== void 0 ? _d : null;
105
- listener.alwaysFirst = (_e = nextOptions.alwaysFirst) !== null && _e !== void 0 ? _e : false;
106
- listener.alwaysLast = (_f = nextOptions.alwaysLast) !== null && _f !== void 0 ? _f : false;
107
- let nextAsync = (_g = nextOptions.async) !== null && _g !== void 0 ? _g : null;
108
- if (nextAsync === true) {
109
- nextAsync = 1;
110
- }
111
- listener.async = nextAsync;
120
+ // Partial update: only fields explicitly present in nextOptions change;
121
+ // any omitted field keeps its current value (a caller changing one
122
+ // option does not silently reset the others). Pass a field explicitly
123
+ // (e.g. limit: 0, signal: null) to clear it.
124
+ if ("limit" in nextOptions) {
125
+ listener.limit = (_a = nextOptions.limit) !== null && _a !== void 0 ? _a : 0;
126
+ }
127
+ if ("start" in nextOptions) {
128
+ listener.start = (_b = nextOptions.start) !== null && _b !== void 0 ? _b : 1;
129
+ }
130
+ if ("tags" in nextOptions) {
131
+ listener.tags = (_c = nextOptions.tags) !== null && _c !== void 0 ? _c : [];
132
+ }
133
+ if ("extraData" in nextOptions) {
134
+ listener.extraData = (_d = nextOptions.extraData) !== null && _d !== void 0 ? _d : null;
135
+ }
136
+ if ("alwaysFirst" in nextOptions) {
137
+ listener.alwaysFirst = (_e = nextOptions.alwaysFirst) !== null && _e !== void 0 ? _e : false;
138
+ }
139
+ if ("alwaysLast" in nextOptions) {
140
+ listener.alwaysLast = (_f = nextOptions.alwaysLast) !== null && _f !== void 0 ? _f : false;
141
+ }
142
+ if ("async" in nextOptions) {
143
+ let nextAsync = (_g = nextOptions.async) !== null && _g !== void 0 ? _g : null;
144
+ if (nextAsync === true) {
145
+ nextAsync = 1;
146
+ }
147
+ listener.async = nextAsync;
148
+ }
112
149
  // Re-sort if ordering hints changed. Unlike addListener we do NOT
113
150
  // rewrite each listener's index here: the existing indices hold the
114
151
  // original insertion order, and preserving them lets sorting restore
@@ -122,6 +159,31 @@ export function createEvent(eventOptions = {}) {
122
159
  listeners.sort((l1, l2) => listenerSorter(l1, l2));
123
160
  }
124
161
  }
162
+ // Rebind the AbortSignal only when `signal` is explicitly present:
163
+ // detach any previous wiring so the old controller can no longer remove
164
+ // this listener, then attach the new signal. Omitting the field leaves
165
+ // the existing binding intact (partial-update convention); pass
166
+ // signal: null to clear it. An already-aborted new signal removes the
167
+ // listener now, mirroring addListener's "do not keep an aborted-signal
168
+ // listener".
169
+ if ("signal" in nextOptions) {
170
+ (_h = listener.abortCleanup) === null || _h === void 0 ? void 0 : _h.call(listener);
171
+ listener.abortCleanup = null;
172
+ const nextSignal = (_j = nextOptions.signal) !== null && _j !== void 0 ? _j : null;
173
+ if (nextSignal) {
174
+ if (nextSignal.aborted) {
175
+ removeListener(listener.handler, listenerContext);
176
+ return true;
177
+ }
178
+ const onAbort = () => {
179
+ removeListener(listener.handler, listenerContext);
180
+ };
181
+ nextSignal.addEventListener("abort", onAbort, { once: true });
182
+ listener.abortCleanup = () => {
183
+ nextSignal.removeEventListener("abort", onAbort);
184
+ };
185
+ }
186
+ }
125
187
  // The core auto-remove check is a strict `called === limit`, so a
126
188
  // listener whose `called` already exceeds the new limit would never
127
189
  // auto-remove. Remove it immediately in that case.
@@ -155,10 +217,16 @@ export function createEvent(eventOptions = {}) {
155
217
  const removeAllListeners = (tag) => {
156
218
  if (tag) {
157
219
  listeners = listeners.filter((l) => {
158
- return !l.tags || l.tags.indexOf(tag) === -1;
220
+ var _a;
221
+ const keep = !l.tags || l.tags.indexOf(tag) === -1;
222
+ if (!keep) {
223
+ (_a = l.abortCleanup) === null || _a === void 0 ? void 0 : _a.call(l);
224
+ }
225
+ return keep;
159
226
  });
160
227
  }
161
228
  else {
229
+ listeners.forEach((l) => { var _a; return (_a = l.abortCleanup) === null || _a === void 0 ? void 0 : _a.call(l); });
162
230
  listeners = [];
163
231
  }
164
232
  };
@@ -197,7 +265,98 @@ export function createEvent(eventOptions = {}) {
197
265
  };
198
266
  const isSuspended = () => suspended;
199
267
  const isQueued = () => queued;
268
+ // One-call teardown: drop all listeners (unwinding their abort handlers via
269
+ // reset) and mark the event dead. Post-destroy trigger/addListener throw
270
+ // rather than silently no-op, surfacing use-after-free.
271
+ const destroy = () => {
272
+ reset();
273
+ destroyed = true;
274
+ };
275
+ const isDestroyed = () => destroyed;
276
+ const listenerCount = (tag) => {
277
+ if (tag) {
278
+ return listeners.filter((l) => l.tags && l.tags.indexOf(tag) !== -1).length;
279
+ }
280
+ return listeners.length;
281
+ };
282
+ const triggeredCount = () => triggered;
283
+ // Return a copy: handing back the internal `lastTrigger` reference would let
284
+ // a caller mutate it, corrupting both the recorded snapshot and the values
285
+ // replayed to autoTrigger listeners.
286
+ const lastTriggerArgs = () => lastTrigger ? lastTrigger.slice() : null;
287
+ // Deep-copy extraData so the read-only projection cannot mutate internal
288
+ // listener metadata (which filters can read) at any depth; a shallow copy
289
+ // still shares nested containers by reference. `seen` carries already-cloned
290
+ // containers so a cyclic graph reuses its clone instead of recursing forever
291
+ // (a plain recursive clone throws RangeError on cycles). Arrays, plain
292
+ // objects, Date, Map and Set are cloned. Truly opaque values (functions,
293
+ // class instances) are returned as-is — copying their enumerable keys would
294
+ // not faithfully reproduce them — as are primitives.
295
+ const projectExtraDataDeep = (value, seen) => {
296
+ if (value === null || typeof value !== "object") {
297
+ return value;
298
+ }
299
+ const existing = seen.get(value);
300
+ if (existing !== undefined) {
301
+ return existing;
302
+ }
303
+ if (value instanceof Date) {
304
+ return new Date(value.getTime());
305
+ }
306
+ if (Array.isArray(value)) {
307
+ const copy = [];
308
+ seen.set(value, copy);
309
+ for (const v of value) {
310
+ copy.push(projectExtraDataDeep(v, seen));
311
+ }
312
+ return copy;
313
+ }
314
+ if (value instanceof Map) {
315
+ const copy = new Map();
316
+ seen.set(value, copy);
317
+ value.forEach((v, k) => {
318
+ copy.set(projectExtraDataDeep(k, seen), projectExtraDataDeep(v, seen));
319
+ });
320
+ return copy;
321
+ }
322
+ if (value instanceof Set) {
323
+ const copy = new Set();
324
+ seen.set(value, copy);
325
+ value.forEach((v) => {
326
+ copy.add(projectExtraDataDeep(v, seen));
327
+ });
328
+ return copy;
329
+ }
330
+ const proto = Object.getPrototypeOf(value);
331
+ if (proto === Object.prototype || proto === null) {
332
+ const copy = {};
333
+ seen.set(value, copy);
334
+ for (const k of Object.keys(value)) {
335
+ copy[k] = projectExtraDataDeep(value[k], seen);
336
+ }
337
+ return copy;
338
+ }
339
+ return value;
340
+ };
341
+ const projectExtraData = (value) => projectExtraDataDeep(value, new WeakMap());
342
+ const getListeners = () => {
343
+ return listeners.map((l) => ({
344
+ handler: l.handler,
345
+ context: l.context,
346
+ tags: l.tags ? l.tags.slice() : [],
347
+ limit: l.limit,
348
+ start: l.start,
349
+ called: l.called,
350
+ count: l.count,
351
+ async: l.async,
352
+ first: l.first,
353
+ alwaysFirst: l.alwaysFirst,
354
+ alwaysLast: l.alwaysLast,
355
+ extraData: projectExtraData(l.extraData),
356
+ }));
357
+ };
200
358
  const reset = () => {
359
+ listeners.forEach((l) => { var _a; return (_a = l.abortCleanup) === null || _a === void 0 ? void 0 : _a.call(l); });
201
360
  listeners.length = 0;
202
361
  errorListeners.length = 0;
203
362
  queue.length = 0;
@@ -205,6 +364,7 @@ export function createEvent(eventOptions = {}) {
205
364
  queued = false;
206
365
  triggered = 0;
207
366
  lastTrigger = null;
367
+ autoTriggerArgs = null;
208
368
  sortListeners = false;
209
369
  };
210
370
  const _listenerCall = (listener, args, resolve = null) => {
@@ -266,6 +426,12 @@ export function createEvent(eventOptions = {}) {
266
426
  };
267
427
  const _listenerCallWPrev = (listener, args, prevValue, returnType) => {
268
428
  if (returnType === TriggerReturnType.PIPE) {
429
+ // Copy-on-write: preserve the pre-pipe lastTrigger snapshot before
430
+ // mutating args[0] in place for the pipe chain (lastTrigger stores
431
+ // the args reference rather than an eager per-trigger copy).
432
+ if (lastTrigger === args) {
433
+ lastTrigger = args.slice();
434
+ }
269
435
  args[0] = prevValue;
270
436
  // since we don't user listener's arg transformer,
271
437
  // we don't need to prepare args
@@ -287,8 +453,16 @@ export function createEvent(eventOptions = {}) {
287
453
  }
288
454
  return _listenerCall(listener, args);
289
455
  };
290
- const _trigger = (args, returnType = null, tags) => {
291
- var _a, _b;
456
+ const _trigger = (args, returnType = null, tags,
457
+ // When set, this call is an autoTrigger replay: it must not bump
458
+ // `triggered`/`lastTrigger` or be gated by the trigger `limit`, and it is
459
+ // delivered only to the listener identified here.
460
+ replayTo) => {
461
+ var _a, _b, _c;
462
+ const replaying = !!replayTo;
463
+ if (destroyed) {
464
+ throw new Error("Event is destroyed");
465
+ }
292
466
  if (queued) {
293
467
  queue.push([
294
468
  args,
@@ -300,12 +474,28 @@ export function createEvent(eventOptions = {}) {
300
474
  if (suspended) {
301
475
  return;
302
476
  }
303
- if (options.limit && triggered >= options.limit) {
477
+ // The trigger `limit` bounds real triggers; an autoTrigger replay is an
478
+ // internal redelivery and must always reach the new listener.
479
+ if (options.limit && triggered >= options.limit && !replaying) {
304
480
  return;
305
481
  }
306
- triggered++;
307
- if (options.autoTrigger) {
308
- lastTrigger = args.slice();
482
+ if (!replaying) {
483
+ triggered++;
484
+ // Record the last trigger arguments for introspection
485
+ // (`lastTriggerArgs`). Store the reference rather than eagerly
486
+ // copying on every trigger (a hot path even when nothing ever reads
487
+ // it): `args` is a fresh per-call array the caller cannot reach, and
488
+ // listeners receive it spread (never the array itself). The snapshot
489
+ // is copied lazily — when handed out by lastTriggerArgs(), and
490
+ // copy-on-write before PIPE mode mutates args[0] in place — so the
491
+ // recorded snapshot stays the pre-pipe arguments.
492
+ lastTrigger = args;
493
+ // Record the replay source only while autoTrigger is enabled, so a
494
+ // late listener added after autoTrigger is turned on replays the
495
+ // most recent *enabled* trigger, never an earlier disabled one.
496
+ if (options.autoTrigger) {
497
+ autoTriggerArgs = args.slice();
498
+ }
309
499
  }
310
500
  // in pipe mode if there is no listeners,
311
501
  // we just return piped value
@@ -336,6 +526,13 @@ export function createEvent(eventOptions = {}) {
336
526
  if (!listener) {
337
527
  continue;
338
528
  }
529
+ // An autoTrigger replay is delivered only to the newly added
530
+ // listener identified by `replayTo`.
531
+ if (replayTo
532
+ && (listener.handler !== replayTo.handler
533
+ || ((_c = listener.context) !== null && _c !== void 0 ? _c : null) !== replayTo.context)) {
534
+ continue;
535
+ }
339
536
  if (options.filter
340
537
  && options.filter.call(options.filterContext, args, listener)
341
538
  === false) {
@@ -357,6 +554,16 @@ export function createEvent(eventOptions = {}) {
357
554
  && listener.count < listener.start) {
358
555
  continue;
359
556
  }
557
+ // Count the call and exhaust the limit BEFORE invoking the handler.
558
+ // If the handler re-triggers this same event, the nested _trigger
559
+ // snapshots `listeners` AFTER the removal below, so an exhausted
560
+ // (e.g. once()) listener is not invoked a second time. Doing this
561
+ // after the call would let a re-entrant trigger see the still-live
562
+ // listener and run it again past its limit.
563
+ listener.called++;
564
+ if (listener.called === listener.limit) {
565
+ removeListener(listener.handler, listener.context);
566
+ }
360
567
  if (isConsequent && results.length > 0) {
361
568
  const prev = results[results.length - 1];
362
569
  if (hasPromises) {
@@ -374,10 +581,6 @@ export function createEvent(eventOptions = {}) {
374
581
  else {
375
582
  listenerResult = _listenerCall(listener, args);
376
583
  }
377
- listener.called++;
378
- if (listener.called === listener.limit) {
379
- removeListener(listener.handler, listener.context);
380
- }
381
584
  if (returnType === TriggerReturnType.FIRST) {
382
585
  return listenerResult;
383
586
  }
@@ -469,6 +672,9 @@ export function createEvent(eventOptions = {}) {
469
672
  currentTagsFilter = prevTagsFilter;
470
673
  }
471
674
  };
675
+ const once = (handler, listenerOptions = {}) => {
676
+ return addListener(handler, Object.assign(Object.assign({}, listenerOptions), { limit: 1 }));
677
+ };
472
678
  const promise = (options) => {
473
679
  return new Promise((resolve) => {
474
680
  options = Object.assign(Object.assign({}, (options || {})), { limit: 1 });
@@ -565,6 +771,7 @@ export function createEvent(eventOptions = {}) {
565
771
  listen: addListener,
566
772
  /** @alias addListener */
567
773
  subscribe: addListener,
774
+ once,
568
775
  removeListener,
569
776
  updateListenerOptions,
570
777
  /** @alias removeListener */
@@ -590,8 +797,14 @@ export function createEvent(eventOptions = {}) {
590
797
  resume,
591
798
  setOptions,
592
799
  reset,
800
+ destroy,
801
+ isDestroyed,
593
802
  isSuspended,
594
803
  isQueued,
804
+ listenerCount,
805
+ triggeredCount,
806
+ lastTriggerArgs,
807
+ getListeners,
595
808
  withTags,
596
809
  promise,
597
810
  first,
@@ -91,6 +91,8 @@ export declare function createEventBus<EventsMap extends BaseEventMap = DefaultE
91
91
  readonly stopIntercepting: () => void;
92
92
  readonly isIntercepting: () => boolean;
93
93
  readonly reset: () => void;
94
+ readonly destroy: () => void;
95
+ readonly isDestroyed: () => boolean;
94
96
  readonly suspendAll: (withQueue?: boolean) => void;
95
97
  readonly resumeAll: () => void;
96
98
  readonly relay: ({ eventSource, remoteEventName, localEventName, proxyType, localEventNamePrefix, }: {
package/dist/eventBus.js CHANGED
@@ -78,6 +78,10 @@ export function createEventBus(eventBusOptions) {
78
78
  let interceptor = null;
79
79
  const proxyListeners = [];
80
80
  const eventSources = [];
81
+ let destroyed = false;
82
+ // Registry of active relays so destroy() can unwind the external listeners
83
+ // they attach (reset()/destroy() otherwise leave them dangling).
84
+ const relays = [];
81
85
  const asterisk = createEvent();
82
86
  const errorEvent = createEvent();
83
87
  const _getProxyListener = ({ remoteEventName, localEventName, returnType, resolve, localEventNamePrefix, }) => {
@@ -131,6 +135,9 @@ export function createEventBus(eventBusOptions) {
131
135
  return listener;
132
136
  };
133
137
  const add = (name, options) => {
138
+ if (destroyed) {
139
+ throw new Error("EventBus is destroyed");
140
+ }
134
141
  if (!events.has(name)) {
135
142
  events.set(name, createEvent(options));
136
143
  }
@@ -173,9 +180,15 @@ export function createEventBus(eventBusOptions) {
173
180
  return interceptor !== null;
174
181
  };
175
182
  const get = (name) => {
183
+ if (destroyed) {
184
+ throw new Error("EventBus is destroyed");
185
+ }
176
186
  return _getOrAddEvent(name);
177
187
  };
178
188
  const on = (name, handler, options) => {
189
+ if (destroyed) {
190
+ throw new Error("EventBus is destroyed");
191
+ }
179
192
  const e = _getOrAddEvent(name);
180
193
  eventSources.forEach((evs) => {
181
194
  if (evs.eventSource.accepts === false
@@ -199,11 +212,12 @@ export function createEventBus(eventBusOptions) {
199
212
  return e.addListener(handler, options);
200
213
  };
201
214
  const once = (name, handler, options) => {
202
- options = options || {};
203
- options.limit = 1;
204
- return on(name, handler, options);
215
+ return on(name, handler, Object.assign(Object.assign({}, (options || {})), { limit: 1 }));
205
216
  };
206
217
  const promise = (name, options) => {
218
+ if (destroyed) {
219
+ throw new Error("EventBus is destroyed");
220
+ }
207
221
  const e = _getOrAddEvent(name);
208
222
  return e.promise(options);
209
223
  };
@@ -239,6 +253,9 @@ export function createEventBus(eventBusOptions) {
239
253
  return e.updateListenerOptions(handler, context !== null && context !== void 0 ? context : null, nextOptions);
240
254
  };
241
255
  const _trigger = (name, args, returnType, resolve) => {
256
+ if (destroyed) {
257
+ throw new Error("EventBus is destroyed");
258
+ }
242
259
  if (name === "*") {
243
260
  return;
244
261
  }
@@ -393,18 +410,70 @@ export function createEventBus(eventBusOptions) {
393
410
  }
394
411
  };
395
412
  const reset = () => {
413
+ // Detach relays BEFORE clearing proxyListeners: unrelay() resolves the
414
+ // external listener via _getProxyListener, which depends on the original
415
+ // entry still being present. Clearing proxyListeners first would lose the
416
+ // callback identity, leaving the external subscription dangling so a
417
+ // later destroy() could never remove it.
418
+ relays.slice().forEach((r) => {
419
+ unrelay({
420
+ eventSource: r.eventSource,
421
+ remoteEventName: r.remoteEventName,
422
+ localEventName: r.localEventName,
423
+ proxyType: r.proxyType,
424
+ localEventNamePrefix: r.localEventNamePrefix,
425
+ });
426
+ });
396
427
  if (eventSources.length > 0) {
397
428
  eventSources.slice().forEach((evs) => {
398
429
  removeEventSource(evs.eventSource);
399
430
  });
400
431
  }
432
+ // Reset each owned event before dropping it: clearing the map alone
433
+ // leaves listener AbortSignal handlers attached to their signals, which
434
+ // retains the orphaned events (and their listeners) until the signal
435
+ // aborts. reset() detaches those handlers and clears the listeners.
436
+ events.forEach((event) => {
437
+ event.reset();
438
+ });
401
439
  events.clear();
402
440
  interceptor = null;
403
441
  currentTagsFilter = null;
404
442
  asterisk.reset();
405
443
  proxyListeners.length = 0;
406
444
  eventSources.length = 0;
445
+ relays.length = 0;
446
+ };
447
+ // One-call teardown: unwind external attachments (relays + event sources)
448
+ // that reset() leaves dangling, destroy every owned event, and mark the bus
449
+ // dead. Post-destroy trigger/addListener throw rather than silently no-op.
450
+ const destroy = () => {
451
+ relays.slice().forEach((r) => {
452
+ unrelay({
453
+ eventSource: r.eventSource,
454
+ remoteEventName: r.remoteEventName,
455
+ localEventName: r.localEventName,
456
+ proxyType: r.proxyType,
457
+ localEventNamePrefix: r.localEventNamePrefix,
458
+ });
459
+ });
460
+ eventSources.slice().forEach((evs) => {
461
+ removeEventSource(evs.eventSource);
462
+ });
463
+ events.forEach((event) => {
464
+ event.destroy();
465
+ });
466
+ events.clear();
467
+ asterisk.destroy();
468
+ errorEvent.destroy();
469
+ proxyListeners.length = 0;
470
+ eventSources.length = 0;
471
+ relays.length = 0;
472
+ interceptor = null;
473
+ currentTagsFilter = null;
474
+ destroyed = true;
407
475
  };
476
+ const isDestroyed = () => destroyed;
408
477
  const suspendAll = (withQueue = false) => {
409
478
  events.forEach((event) => {
410
479
  event.suspend(withQueue);
@@ -416,6 +485,12 @@ export function createEventBus(eventBusOptions) {
416
485
  });
417
486
  };
418
487
  const relay = ({ eventSource, remoteEventName, localEventName, proxyType, localEventNamePrefix, }) => {
488
+ // Like the other registration methods, refuse on a dead bus: attaching
489
+ // the proxy listener here would leave a dangling subscription that
490
+ // throws "EventBus is destroyed" the next time the source fires.
491
+ if (destroyed) {
492
+ throw new Error("EventBus is destroyed");
493
+ }
419
494
  const { returnType, resolve } = proxyReturnTypeToTriggerReturnType(proxyType || ProxyType.TRIGGER);
420
495
  const listener = _getProxyListener({
421
496
  localEventName: localEventName || null,
@@ -430,6 +505,18 @@ export function createEventBus(eventBusOptions) {
430
505
  else {
431
506
  eventSource.on(remoteEventName, listener.listener);
432
507
  }
508
+ relays.push({
509
+ eventSource,
510
+ remoteEventName,
511
+ localEventName: localEventName || null,
512
+ // Store the resolved proxyType (undefined resolves to TRIGGER) so the
513
+ // registry key matches how the proxy listener is actually resolved.
514
+ // Otherwise unrelay({ proxyType: TRIGGER }) for a relay({}) (undefined)
515
+ // detaches the listener but leaves a stale registry entry, which a
516
+ // later reset()/destroy() then unrelays a second time.
517
+ proxyType: proxyType || ProxyType.TRIGGER,
518
+ localEventNamePrefix: localEventNamePrefix || null,
519
+ });
433
520
  };
434
521
  const unrelay = ({ eventSource, remoteEventName, localEventName, proxyType, localEventNamePrefix, }) => {
435
522
  const { returnType, resolve } = proxyReturnTypeToTriggerReturnType(proxyType || ProxyType.TRIGGER);
@@ -448,8 +535,22 @@ export function createEventBus(eventBusOptions) {
448
535
  eventSource.un(remoteEventName, listener.listener);
449
536
  }
450
537
  }
538
+ const inx = relays.findIndex((r) => r.eventSource === eventSource
539
+ && r.remoteEventName === remoteEventName
540
+ && r.localEventName === (localEventName || null)
541
+ // Compare on the resolved proxyType so an undefined relay matches an
542
+ // explicit TRIGGER unrelay (and vice versa), mirroring how the proxy
543
+ // listener itself resolves equivalent types.
544
+ && r.proxyType === (proxyType || ProxyType.TRIGGER)
545
+ && r.localEventNamePrefix === (localEventNamePrefix || null));
546
+ if (inx !== -1) {
547
+ relays.splice(inx, 1);
548
+ }
451
549
  };
452
550
  const addEventSource = (eventSource) => {
551
+ if (destroyed) {
552
+ throw new Error("EventBus is destroyed");
553
+ }
453
554
  if (eventSources.find((evs) => evs.eventSource.name === eventSource.name)) {
454
555
  return;
455
556
  }
@@ -549,6 +650,8 @@ export function createEventBus(eventBusOptions) {
549
650
  stopIntercepting,
550
651
  isIntercepting,
551
652
  reset,
653
+ destroy,
654
+ isDestroyed,
552
655
  suspendAll,
553
656
  resumeAll,
554
657
  relay,
@@ -44,6 +44,6 @@ export type ErrorResponse<Arguments extends any[] = any[]> = {
44
44
  error: Error;
45
45
  args: Arguments;
46
46
  name?: MapKey;
47
- type: "action" | "event" | "store-change" | "store-pipe" | "store-control";
47
+ type: "action" | "action-status" | "event" | "store-change" | "store-pipe" | "store-control";
48
48
  };
49
49
  export type ErrorListenerSignature<Arguments extends any[] = any[]> = (errorResponse: ErrorResponse<Arguments>) => void;
@@ -0,0 +1,13 @@
1
+ import type { ActionStatus } from "../action.js";
2
+ import type { BaseActionBus } from "../actionBus.js";
3
+ import type { KeyOf } from "../lib/types.js";
4
+ import type { AsyncActionState } from "./useAsyncAction.js";
5
+ export type { ActionStatus, AsyncActionState };
6
+ /**
7
+ * Subscribes to the status of a named action on an ActionBus and returns
8
+ * `{ loading, error, response }` for driving `loading`/`disabled` UI. This is
9
+ * the primary path for apps that route mutations through one shared ActionBus.
10
+ *
11
+ * An unregistered name reports an idle status and is safe to subscribe to.
12
+ */
13
+ export declare function useActionBusStatus<TBus extends BaseActionBus, TName extends KeyOf<TBus["__type"]["actions"]>>(bus: TBus, name: TName): AsyncActionState<TBus["__type"]["actions"][TName]["actionReturnType"]>;