@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/dist/store.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { EventBusDefinitionHelper } from "./eventBus";
2
- import type { ApiType, ErrorListenerSignature, KeyOf, MapKey } from "./lib/types";
1
+ import { EventBusDefinitionHelper } from "./eventBus.js";
2
+ import type { ApiType, ErrorListenerSignature, KeyOf, MapKey } from "./lib/types.js";
3
3
  export interface BasePropMap {
4
4
  [key: MapKey]: any;
5
5
  }
@@ -42,14 +42,18 @@ export declare function createStore<PropMap extends BasePropMap = BasePropMap>(i
42
42
  <K extends KeyOf<PropMap>>(key: K, value: PropMap[K] | undefined): void;
43
43
  (key: Partial<PropMap>): void;
44
44
  };
45
+ readonly computed: <K extends KeyOf<PropMap>, const D extends readonly KeyOf<PropMap>[]>(key: K, deps: D, fn: (...values: { [I in keyof D]: PropMap[D[I]] | undefined; }) => PropMap[K]) => void;
45
46
  readonly isEmpty: () => boolean;
46
47
  readonly reset: () => void;
47
- readonly onChange: <K extends KeyOf<import("./eventBus").GetEventsMap<StoreChangeEvents<PropMap>>>, H extends import("./eventBus").GetEventsMap<StoreChangeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, options?: import("./event").ListenerOptions) => void;
48
- readonly removeOnChange: <K extends KeyOf<import("./eventBus").GetEventsMap<StoreChangeEvents<PropMap>>>, H extends import("./eventBus").GetEventsMap<StoreChangeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, context?: object | null, tag?: string | null) => void;
49
- readonly control: <K extends KeyOf<import("./eventBus").GetEventsMap<StoreControlEvents<PropMap>>>, H extends import("./eventBus").GetEventsMap<StoreControlEvents<PropMap>>[K]["signature"]>(name: K, handler: H, options?: import("./event").ListenerOptions) => void;
50
- readonly removeControl: <K extends KeyOf<import("./eventBus").GetEventsMap<StoreControlEvents<PropMap>>>, H extends import("./eventBus").GetEventsMap<StoreControlEvents<PropMap>>[K]["signature"]>(name: K, handler: H, context?: object | null, tag?: string | null) => void;
51
- readonly pipe: <K extends KeyOf<import("./eventBus").GetEventsMap<StorePipeEvents<PropMap>>>, H extends import("./eventBus").GetEventsMap<StorePipeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, options?: import("./event").ListenerOptions) => void;
52
- readonly removePipe: <K extends KeyOf<import("./eventBus").GetEventsMap<StorePipeEvents<PropMap>>>, H extends import("./eventBus").GetEventsMap<StorePipeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, context?: object | null, tag?: string | null) => void;
48
+ readonly destroy: () => void;
49
+ readonly isDestroyed: () => boolean;
50
+ readonly onChange: <K extends KeyOf<import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>>, H extends import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, options?: import("./event.js").ListenerOptions) => void;
51
+ readonly removeOnChange: <K extends KeyOf<import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>>, H extends import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, context?: object | null, tag?: string | null) => void;
52
+ readonly updateOnChangeOptions: <K extends KeyOf<import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>>, H extends import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, context?: object | null, nextOptions?: import("./event.js").ListenerOptions) => boolean;
53
+ readonly control: <K extends KeyOf<import("./eventBus.js").GetEventsMap<StoreControlEvents<PropMap>>>, H extends import("./eventBus.js").GetEventsMap<StoreControlEvents<PropMap>>[K]["signature"]>(name: K, handler: H, options?: import("./event.js").ListenerOptions) => void;
54
+ readonly removeControl: <K extends KeyOf<import("./eventBus.js").GetEventsMap<StoreControlEvents<PropMap>>>, H extends import("./eventBus.js").GetEventsMap<StoreControlEvents<PropMap>>[K]["signature"]>(name: K, handler: H, context?: object | null, tag?: string | null) => void;
55
+ readonly pipe: <K extends KeyOf<import("./eventBus.js").GetEventsMap<StorePipeEvents<PropMap>>>, H extends import("./eventBus.js").GetEventsMap<StorePipeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, options?: import("./event.js").ListenerOptions) => void;
56
+ readonly removePipe: <K extends KeyOf<import("./eventBus.js").GetEventsMap<StorePipeEvents<PropMap>>>, H extends import("./eventBus.js").GetEventsMap<StorePipeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, context?: object | null, tag?: string | null) => void;
53
57
  }>;
54
58
  export type BaseStoreDefinition = StoreDefinitionHelper<BasePropMap>;
55
59
  export type BaseStore = ReturnType<typeof createStore<any>>;
package/dist/store.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createEventBus } from "./eventBus";
1
+ import { createEventBus } from "./eventBus.js";
2
2
  export const BeforeChangeEventName = "before";
3
3
  export const ChangeEventName = "change";
4
4
  export const ResetEventName = "reset";
@@ -10,19 +10,82 @@ export function createStore(initialData = {}) {
10
10
  const pipe = createEventBus();
11
11
  const control = createEventBus();
12
12
  let effectKeys = [];
13
+ // Computed keys are read-only via the public `set` and recompute via the
14
+ // `effect` control event. `computingKeys` is a per-key re-entrancy guard so
15
+ // a cyclic computed throws instead of looping forever.
16
+ const computedKeys = new Set();
17
+ const computingKeys = new Set();
18
+ // Re-seed closures keyed in registration order so reset() can recompute each
19
+ // computed value from the (now cleared) deps. Without this, reset() clears
20
+ // `data` but leaves computed keys stale/undefined while still marked
21
+ // read-only, so they no longer equal fn(deps).
22
+ const computedReseeders = new Map();
23
+ // The effect listener installed for each computed key, so re-registering the
24
+ // same key detaches the previous recompute closure instead of leaving it
25
+ // attached (which would run a stale fn on every dependency change).
26
+ const computedEffectListeners = new Map();
27
+ // Multi-key object sets write every base value first with effect emission
28
+ // deferred (`deferEffects`), then replay effects once so computed values
29
+ // recompute from the final state instead of once per intermediate write.
30
+ // `computedBatch`, when active, records the dependency values each computed
31
+ // last recomputed from in this batch. A computed is skipped only when its
32
+ // deps are unchanged since then — so a redundant dep change is a no-op, but
33
+ // a dependent in a computed chain still recomputes once its upstream
34
+ // computed updates (rather than settling on a stale early value).
35
+ let deferEffects = false;
36
+ let computedBatch = null;
37
+ const arraysShallowEqual = (a, b) => {
38
+ if (a.length !== b.length) {
39
+ return false;
40
+ }
41
+ for (let i = 0; i < a.length; i++) {
42
+ if (a[i] !== b[i]) {
43
+ return false;
44
+ }
45
+ }
46
+ return true;
47
+ };
48
+ let destroyed = false;
49
+ // Timers scheduled by asyncSet, tracked so destroy() can cancel them.
50
+ // Otherwise a pending callback fires after teardown and throws "Store is
51
+ // destroyed" from inside the timer.
52
+ const pendingTimers = new Set();
53
+ const assertAlive = () => {
54
+ if (destroyed) {
55
+ throw new Error("Store is destroyed");
56
+ }
57
+ };
58
+ const dedupe = (keys) => Array.from(new Set(keys));
59
+ // A public set() can trigger a computed cascade. Routing it through batch()
60
+ // makes that cascade glitch-free (one coalesced onChange per affected key).
61
+ // Only do so at the top level: if already batching or intercepting, the
62
+ // surrounding operation coalesces; with no effect listener there is no
63
+ // cascade to coalesce.
64
+ const canCoalesceCascade = () => {
65
+ var _a;
66
+ return !batching
67
+ && !changes.isIntercepting()
68
+ && !control.isIntercepting()
69
+ && !!((_a = control.get(EffectEventName)) === null || _a === void 0 ? void 0 : _a.hasListener());
70
+ };
13
71
  const effectInterceptor = (name, args) => {
14
72
  if (name === ChangeEventName) {
15
73
  effectKeys.push(...args[0]);
16
74
  return false;
17
75
  }
76
+ // While the multi-key loop writes base values, swallow effect emissions;
77
+ // they are replayed once afterwards against the final state.
78
+ if (name === EffectEventName && deferEffects) {
79
+ return false;
80
+ }
18
81
  return true;
19
82
  };
20
83
  const _set = (name, value, triggerChange = true) => {
21
84
  var _a, _b, _c, _d, _e;
22
85
  const prev = data.get(name);
23
86
  if (prev !== value) {
24
- if (control.firstNonEmpty(BeforeChangeEventName, name, value)
25
- === false) {
87
+ const beforeChangeResults = control.all(BeforeChangeEventName, name, value);
88
+ if (beforeChangeResults.some((result) => result === false)) {
26
89
  return;
27
90
  }
28
91
  const pipeArgs = [value];
@@ -73,12 +136,16 @@ export function createStore(initialData = {}) {
73
136
  if ((_c = control.get(EffectEventName)) === null || _c === void 0 ? void 0 : _c.hasListener()) {
74
137
  try {
75
138
  const isIntercepting = control.isIntercepting();
76
- if (!isIntercepting) {
77
- control.intercept(effectInterceptor);
139
+ try {
140
+ if (!isIntercepting) {
141
+ control.intercept(effectInterceptor);
142
+ }
143
+ control.trigger(EffectEventName, name, value);
78
144
  }
79
- control.trigger(EffectEventName, name, value);
80
- if (!isIntercepting) {
81
- control.stopIntercepting();
145
+ finally {
146
+ if (!isIntercepting) {
147
+ control.stopIntercepting();
148
+ }
82
149
  }
83
150
  }
84
151
  catch (error) {
@@ -94,12 +161,16 @@ export function createStore(initialData = {}) {
94
161
  effectKeys = [];
95
162
  return true;
96
163
  }
164
+ // Clear before propagating: an unhandled throw here would
165
+ // otherwise leave the cascade's collected keys dirty for the
166
+ // next _set, which would report them as spuriously changed.
167
+ effectKeys = [];
97
168
  throw error;
98
169
  }
99
170
  }
100
171
  if (triggerChange) {
101
172
  try {
102
- control.trigger(ChangeEventName, [name, ...effectKeys]);
173
+ control.trigger(ChangeEventName, dedupe([name, ...effectKeys]));
103
174
  if (!control.isIntercepting()) {
104
175
  effectKeys = [];
105
176
  }
@@ -117,6 +188,9 @@ export function createStore(initialData = {}) {
117
188
  effectKeys = [];
118
189
  return true;
119
190
  }
191
+ // Clear before propagating (see the effect-trigger catch
192
+ // above): a leaked effectKeys would taint the next _set.
193
+ effectKeys = [];
120
194
  throw error;
121
195
  }
122
196
  }
@@ -125,7 +199,12 @@ export function createStore(initialData = {}) {
125
199
  return false;
126
200
  };
127
201
  function asyncSet(name, value) {
128
- setTimeout(() => {
202
+ const timer = setTimeout(() => {
203
+ pendingTimers.delete(timer);
204
+ // The store may have been destroyed between scheduling and firing.
205
+ if (destroyed) {
206
+ return;
207
+ }
129
208
  if (typeof name === "string") {
130
209
  set(name, value);
131
210
  }
@@ -133,53 +212,228 @@ export function createStore(initialData = {}) {
133
212
  set(name);
134
213
  }
135
214
  }, 0);
215
+ pendingTimers.add(timer);
136
216
  }
137
- function set(name, value) {
217
+ // Replay a coalesced change log: one onChange per key, keeping the first
218
+ // entry's pre-cascade `prev` and the last entry's settled `value`, dropping
219
+ // keys whose net value is unchanged. Mirrors batch()'s replay (including its
220
+ // store-change error routing) so a computed touched several times during a
221
+ // cascade emits a single, internally-consistent onChange.
222
+ const replayCoalescedChanges = (log, hasCallbackError, callbackError) => {
223
+ var _a;
224
+ const coalesced = new Map();
225
+ for (const [propName, value, prev] of log) {
226
+ const existing = coalesced.get(propName);
227
+ if (existing) {
228
+ existing.value = value;
229
+ }
230
+ else {
231
+ coalesced.set(propName, { value, prev });
232
+ }
233
+ }
234
+ for (const [propName, { value, prev }] of coalesced) {
235
+ if (value === prev) {
236
+ continue;
237
+ }
238
+ const changeArgs = [
239
+ value,
240
+ prev,
241
+ ];
242
+ try {
243
+ changes.trigger(propName, ...changeArgs);
244
+ }
245
+ catch (error) {
246
+ control.trigger(ErrorEventName, {
247
+ error: error instanceof Error
248
+ ? error
249
+ : new Error(String(error)),
250
+ args: changeArgs,
251
+ type: "store-change",
252
+ name: propName,
253
+ });
254
+ if ((_a = control.get(ErrorEventName)) === null || _a === void 0 ? void 0 : _a.hasListener()) {
255
+ continue;
256
+ }
257
+ if (hasCallbackError) {
258
+ continue;
259
+ }
260
+ throw error;
261
+ }
262
+ }
263
+ if (hasCallbackError) {
264
+ throw callbackError;
265
+ }
266
+ };
267
+ // Run `fn` with onChange emissions intercepted and coalesced. The control
268
+ // change-key collection (effectKeys / effectInterceptor ordering) inside
269
+ // `fn` is untouched, so only the per-key onChange stream is deduped.
270
+ const withChangeCoalescing = (fn, liveKey) => {
271
+ const log = [];
272
+ let liveDelivered = false;
273
+ const logger = function (propName, args) {
274
+ // Deliver the directly-set key's onChange live (it fires inside _set
275
+ // before the effect cascade) so plain onChange listeners still run
276
+ // ahead of effect listeners; only the cascade's emissions are
277
+ // coalesced. Returning a non-false value lets the interceptor pass
278
+ // the event through to its listeners.
279
+ if (!liveDelivered && liveKey !== undefined && propName === liveKey) {
280
+ liveDelivered = true;
281
+ return true;
282
+ }
283
+ log.push([propName, args[0], args[1]]);
284
+ return false;
285
+ };
286
+ changes.intercept(logger);
287
+ let callbackError;
288
+ let hasCallbackError = false;
289
+ try {
290
+ fn();
291
+ }
292
+ catch (error) {
293
+ callbackError = error;
294
+ hasCallbackError = true;
295
+ }
296
+ finally {
297
+ changes.stopIntercepting();
298
+ }
299
+ replayCoalescedChanges(log, hasCallbackError, callbackError);
300
+ };
301
+ // The write path shared by set() and its coalescing wrapper. Computed-key
302
+ // validation happens in set() before this runs.
303
+ const applySet = (name, value) => {
138
304
  var _a, _b;
139
305
  if (typeof name === "string") {
140
306
  _set(name, value);
307
+ return;
141
308
  }
142
- else if (typeof name === "object") {
143
- const changedKeys = [];
144
- const isIntercepting = control.isIntercepting();
145
- const hasEffectListener = (_a = control.get(EffectEventName)) === null || _a === void 0 ? void 0 : _a.hasListener();
146
- if (hasEffectListener && !isIntercepting) {
147
- control.intercept(effectInterceptor);
309
+ const changedKeys = [];
310
+ const isIntercepting = control.isIntercepting();
311
+ const hasEffectListener = (_a = control.get(EffectEventName)) === null || _a === void 0 ? void 0 : _a.hasListener();
312
+ const shouldInterceptEffects = hasEffectListener && !isIntercepting;
313
+ let controlError = null;
314
+ if (shouldInterceptEffects) {
315
+ control.intercept(effectInterceptor);
316
+ computedBatch = new Map();
317
+ }
318
+ let allChangedKeys = [];
319
+ try {
320
+ // Phase 1: write every base value with effect emission deferred,
321
+ // so computed values do not recompute against a half-updated
322
+ // state mid-loop.
323
+ if (shouldInterceptEffects) {
324
+ deferEffects = true;
148
325
  }
149
- Object.entries(name).forEach(([k, v]) => {
326
+ const entries = Object.entries(name);
327
+ entries.forEach(([k, v]) => {
150
328
  if (_set(k, v, false)) {
151
329
  changedKeys.push(k);
152
330
  }
153
331
  });
332
+ // Phase 2: with all base values final, replay the effect once per
333
+ // changed key. `computedBatch` skips a computed whose dependency
334
+ // values are unchanged since its last recompute, while still
335
+ // letting chained computeds re-settle when an upstream updates.
336
+ if (shouldInterceptEffects) {
337
+ deferEffects = false;
338
+ changedKeys.forEach((k) => {
339
+ var _a;
340
+ // Mirror _set's effect error contract: a throwing effect
341
+ // listener routes to the error event (and is swallowed if
342
+ // a handler exists) rather than aborting the whole set.
343
+ try {
344
+ control.trigger(EffectEventName, k, data.get(k));
345
+ }
346
+ catch (error) {
347
+ control.trigger(ErrorEventName, {
348
+ error: error instanceof Error
349
+ ? error
350
+ : new Error(String(error)),
351
+ args: [k],
352
+ type: "store-control",
353
+ name: k,
354
+ });
355
+ if (!((_a = control.get(ErrorEventName)) === null || _a === void 0 ? void 0 : _a.hasListener())) {
356
+ throw error;
357
+ }
358
+ }
359
+ });
360
+ }
361
+ allChangedKeys = dedupe([
362
+ ...changedKeys,
363
+ ...effectKeys,
364
+ ]);
365
+ }
366
+ finally {
367
+ if (shouldInterceptEffects) {
368
+ effectKeys = [];
369
+ deferEffects = false;
370
+ computedBatch = null;
371
+ control.stopIntercepting();
372
+ }
373
+ }
374
+ // Fire the outer change AFTER intercepting stops; otherwise the
375
+ // effectInterceptor (active during the loop to fold computed/effect
376
+ // writes into effectKeys) would swallow this trigger too.
377
+ if (allChangedKeys.length > 0) {
154
378
  try {
155
- control.trigger(ChangeEventName, [
156
- ...changedKeys,
157
- ...effectKeys,
158
- ]);
159
- if (hasEffectListener && !isIntercepting) {
160
- effectKeys = [];
161
- control.stopIntercepting();
162
- }
379
+ control.trigger(ChangeEventName, allChangedKeys);
163
380
  }
164
381
  catch (error) {
165
- control.trigger(ErrorEventName, {
166
- error: error instanceof Error
167
- ? error
168
- : new Error(String(error)),
169
- args: [name],
170
- type: "store-control",
171
- });
172
- if ((_b = control.get(ErrorEventName)) === null || _b === void 0 ? void 0 : _b.hasListener()) {
173
- return;
382
+ controlError = error instanceof Error
383
+ ? error
384
+ : new Error(String(error));
385
+ }
386
+ }
387
+ if (controlError) {
388
+ control.trigger(ErrorEventName, {
389
+ error: controlError,
390
+ args: [name],
391
+ type: "store-control",
392
+ });
393
+ if ((_b = control.get(ErrorEventName)) === null || _b === void 0 ? void 0 : _b.hasListener()) {
394
+ return;
395
+ }
396
+ throw controlError;
397
+ }
398
+ };
399
+ function set(name, value) {
400
+ assertAlive();
401
+ if (typeof name === "string") {
402
+ if (computedKeys.has(name)) {
403
+ throw new Error(`Cannot set computed property "${name}"`);
404
+ }
405
+ }
406
+ else if (typeof name === "object") {
407
+ // Validate all keys before any write so a computed key in the patch
408
+ // throws without partially applying the others.
409
+ for (const k of Object.keys(name)) {
410
+ if (computedKeys.has(k)) {
411
+ throw new Error(`Cannot set computed property "${k}"`);
174
412
  }
175
- throw error;
176
413
  }
177
414
  }
178
415
  else {
179
416
  throw new Error(`Invalid key: ${String(name)}`);
180
417
  }
418
+ // A cascade-triggering set coalesces its onChange emissions: a computed
419
+ // touched several times during the cascade (e.g. a diamond sink
420
+ // recomputed once per upstream) fires onChange once with its settled
421
+ // value and the real pre-set prev, instead of leaking an internally
422
+ // inconsistent intermediate (b_new + c_old) with a wrong prev. The
423
+ // control change-key collection is left untouched, so change-key
424
+ // ordering is unaffected.
425
+ if (canCoalesceCascade()) {
426
+ // For a single-key set, deliver that key's onChange live (before the
427
+ // effect cascade) and coalesce only the downstream computed-key
428
+ // emissions. A multi-key (object) set is an explicit batch, so all
429
+ // of its onChange emissions remain coalesced.
430
+ withChangeCoalescing(() => applySet(name, value), typeof name === "string" ? name : undefined);
431
+ return;
432
+ }
433
+ applySet(name, value);
181
434
  }
182
435
  const get = (key) => {
436
+ assertAlive();
183
437
  if (typeof key === "string") {
184
438
  const value = data.get(key);
185
439
  return value;
@@ -207,6 +461,7 @@ export function createStore(initialData = {}) {
207
461
  };
208
462
  let batching = false;
209
463
  const batch = (fn) => {
464
+ var _a, _b;
210
465
  if (batching) {
211
466
  throw new Error("Nested batch() calls are not supported");
212
467
  }
@@ -226,39 +481,218 @@ export function createStore(initialData = {}) {
226
481
  };
227
482
  changes.intercept(changeInterceptor);
228
483
  control.intercept(controlInterceptor);
484
+ let callbackError;
485
+ let hasCallbackError = false;
229
486
  try {
230
487
  fn();
231
488
  }
489
+ catch (error) {
490
+ callbackError = error;
491
+ hasCallbackError = true;
492
+ }
232
493
  finally {
233
494
  control.stopIntercepting();
234
495
  changes.stopIntercepting();
235
496
  batching = false;
236
497
  }
498
+ // Coalesce the log per key before replaying: a key written multiple
499
+ // times in the batch (e.g. a computed recomputing once per base-key
500
+ // write) must fire onChange once with its final value, not once per
501
+ // intermediate value. Keep first-occurrence order, the pre-batch `prev`
502
+ // from the first entry, and the final `value` from the last entry; drop
503
+ // keys whose net value is unchanged from before the batch.
504
+ const coalesced = new Map();
237
505
  for (const [propName, value, prev] of log) {
506
+ const existing = coalesced.get(propName);
507
+ if (existing) {
508
+ existing.value = value;
509
+ }
510
+ else {
511
+ coalesced.set(propName, { value, prev });
512
+ }
513
+ }
514
+ for (const [propName, { value, prev }] of coalesced) {
515
+ if (value === prev) {
516
+ continue;
517
+ }
238
518
  const changeArgs = [
239
519
  value,
240
520
  prev,
241
521
  ];
242
- changes.trigger(propName, ...changeArgs);
522
+ try {
523
+ changes.trigger(propName, ...changeArgs);
524
+ }
525
+ catch (error) {
526
+ control.trigger(ErrorEventName, {
527
+ error: error instanceof Error
528
+ ? error
529
+ : new Error(String(error)),
530
+ args: changeArgs,
531
+ type: "store-change",
532
+ name: propName,
533
+ });
534
+ if ((_a = control.get(ErrorEventName)) === null || _a === void 0 ? void 0 : _a.hasListener()) {
535
+ continue;
536
+ }
537
+ if (hasCallbackError) {
538
+ continue;
539
+ }
540
+ throw error;
541
+ }
243
542
  }
244
- if (allChangedKeys.length > 0) {
245
- control.trigger(ChangeEventName, allChangedKeys);
543
+ // Dedupe so the control change event lists each key once, matching the
544
+ // non-batch path (which dedupes via `dedupe([name, ...effectKeys])`).
545
+ // A computed touched by several base-key writes would otherwise repeat.
546
+ const dedupedChangedKeys = dedupe(allChangedKeys);
547
+ if (dedupedChangedKeys.length > 0) {
548
+ try {
549
+ control.trigger(ChangeEventName, dedupedChangedKeys);
550
+ }
551
+ catch (error) {
552
+ control.trigger(ErrorEventName, {
553
+ error: error instanceof Error
554
+ ? error
555
+ : new Error(String(error)),
556
+ args: [dedupedChangedKeys],
557
+ type: "store-control",
558
+ });
559
+ if ((_b = control.get(ErrorEventName)) === null || _b === void 0 ? void 0 : _b.hasListener()) {
560
+ if (hasCallbackError) {
561
+ throw callbackError;
562
+ }
563
+ return;
564
+ }
565
+ if (hasCallbackError) {
566
+ throw callbackError;
567
+ }
568
+ throw error;
569
+ }
570
+ }
571
+ if (hasCallbackError) {
572
+ throw callbackError;
246
573
  }
247
574
  };
248
575
  const reset = () => {
249
576
  data.clear();
577
+ // Recompute computed keys from the cleared deps (registration order, so a
578
+ // base computed re-seeds before a dependent in a chain) and re-seed them
579
+ // silently, keeping each consistent with fn(deps) instead of stale.
580
+ computedReseeders.forEach((reseed) => {
581
+ reseed();
582
+ });
250
583
  control.trigger(ResetEventName);
251
584
  };
585
+ // One-call teardown: destroy the underlying change/pipe/control buses and
586
+ // drop all data. Post-destroy set/get throw rather than silently no-op.
587
+ const destroy = () => {
588
+ pendingTimers.forEach((timer) => clearTimeout(timer));
589
+ pendingTimers.clear();
590
+ changes.destroy();
591
+ pipe.destroy();
592
+ control.destroy();
593
+ data.clear();
594
+ computedKeys.clear();
595
+ computingKeys.clear();
596
+ computedReseeders.clear();
597
+ computedEffectListeners.clear();
598
+ effectKeys = [];
599
+ destroyed = true;
600
+ };
601
+ const isDestroyed = () => destroyed;
602
+ // Registers `key` as a derived value recomputed from `deps`. Built as sugar
603
+ // over the `effect` control event: recompute writes via the internal `_set`
604
+ // (triggerChange = true) so the change folds into the same outer `change`
605
+ // batch via `effectKeys`. Computed keys flow transparently through
606
+ // get/getData/onChange/useStoreState/useStoreSelector.
607
+ //
608
+ // Recompute is registration-order, not topologically sorted, so a dependent
609
+ // (chain or diamond) may recompute from a stale upstream first. The internal
610
+ // intermediate recompute is invisible to consumers: set() coalesces the
611
+ // onChange stream for a cascade, so each computed fires onChange once with
612
+ // its settled value and the real pre-set prev (`computedBatch` also dedupes
613
+ // redundant recomputes within a multi-key set). The final get()/onChange
614
+ // value is always correct.
615
+ const computed = (key, deps, fn) => {
616
+ // Bail before running user code or seeding data: on a destroyed store
617
+ // the control bus throws only when the recompute listener is attached,
618
+ // by which point fn() has already run and the initial value has been
619
+ // written, repopulating cleared state that getData() would then expose.
620
+ assertAlive();
621
+ const readDeps = () => deps.map((d) => data.get(d));
622
+ // Compute the initial value BEFORE committing any registration state.
623
+ // If `fn` throws here, nothing has been mutated, so the key does not
624
+ // become a permanently read-only computed with no listener installed.
625
+ const initialValue = fn(...readDeps());
626
+ // Seed silently (no change emitted at setup time) but through pipe, so
627
+ // the value read right after computed()/reset() matches what every
628
+ // later _set-driven recompute produces; otherwise a piped key silently
629
+ // changes shape on the first dependency change. beforeChange is skipped
630
+ // on purpose: a silent seed has no change to veto, and a computed key
631
+ // must always hold a value.
632
+ const seed = (raw) => {
633
+ const pipeArgs = [raw];
634
+ const piped = pipe.pipe(key, ...pipeArgs);
635
+ data.set(key, piped !== undefined ? piped : raw);
636
+ };
637
+ seed(initialValue);
638
+ computedKeys.add(key);
639
+ // reset() re-runs this to recompute the value from the cleared deps. It
640
+ // seeds `data` silently (no change emitted), matching this setup path.
641
+ computedReseeders.set(key, () => {
642
+ seed(fn(...readDeps()));
643
+ });
644
+ // Detach a prior recompute closure for this key (re-registration)
645
+ // before installing the new one, so the old fn does not keep running on
646
+ // every dependency change.
647
+ const prevEffect = computedEffectListeners.get(key);
648
+ if (prevEffect) {
649
+ control.removeListener(EffectEventName, prevEffect);
650
+ }
651
+ const effectListener = (changedName) => {
652
+ if (deps.indexOf(changedName) === -1) {
653
+ return;
654
+ }
655
+ const depValues = readDeps();
656
+ // Within a single multi-key set, skip the recompute only when this
657
+ // computed's dependency values are unchanged since its last
658
+ // recompute in the batch. That dedupes redundant dep changes while
659
+ // still re-settling a chain when an upstream computed updates.
660
+ if (computedBatch) {
661
+ const lastDepValues = computedBatch.get(key);
662
+ if (lastDepValues
663
+ && arraysShallowEqual(lastDepValues, depValues)) {
664
+ return;
665
+ }
666
+ computedBatch.set(key, depValues);
667
+ }
668
+ if (computingKeys.has(key)) {
669
+ throw new Error(`Cyclic computed dependency detected at "${key}"`);
670
+ }
671
+ computingKeys.add(key);
672
+ try {
673
+ _set(key, fn(...depValues));
674
+ }
675
+ finally {
676
+ computingKeys.delete(key);
677
+ }
678
+ };
679
+ control.addListener(EffectEventName, effectListener);
680
+ computedEffectListeners.set(key, effectListener);
681
+ };
252
682
  const api = {
253
683
  set,
254
684
  get,
255
685
  getData,
256
686
  batch,
257
687
  asyncSet,
688
+ computed,
258
689
  isEmpty,
259
690
  reset,
691
+ destroy,
692
+ isDestroyed,
260
693
  onChange: changes.addListener,
261
694
  removeOnChange: changes.removeListener,
695
+ updateOnChangeOptions: changes.updateListenerOptions,
262
696
  control: control.addListener,
263
697
  removeControl: control.removeListener,
264
698
  pipe: pipe.addListener,