@kuindji/reactive 1.1.0 → 1.3.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/store.js CHANGED
@@ -10,20 +10,90 @@ 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
- const _set = (name, value, triggerChange = true) => {
83
+ const _set = (name, value, triggerChange = true, runBeforeChange = true) => {
21
84
  var _a, _b, _c, _d, _e;
22
85
  const prev = data.get(name);
23
86
  if (prev !== value) {
24
- const beforeChangeResults = control.all(BeforeChangeEventName, name, value);
25
- if (beforeChangeResults.some((result) => result === false)) {
26
- return;
87
+ // A computed recompute skips the beforeChange veto (runBeforeChange
88
+ // false): the initial computed seed() bypasses beforeChange for the
89
+ // same reason, so allowing a veto here would leave the derived value
90
+ // stale and no longer equal to fn(deps) — internally inconsistent
91
+ // with the seeded value. beforeChange still gates ordinary sets.
92
+ if (runBeforeChange) {
93
+ const beforeChangeResults = control.all(BeforeChangeEventName, name, value);
94
+ if (beforeChangeResults.some((result) => result === false)) {
95
+ return;
96
+ }
27
97
  }
28
98
  const pipeArgs = [value];
29
99
  let newValue;
@@ -98,12 +168,16 @@ export function createStore(initialData = {}) {
98
168
  effectKeys = [];
99
169
  return true;
100
170
  }
171
+ // Clear before propagating: an unhandled throw here would
172
+ // otherwise leave the cascade's collected keys dirty for the
173
+ // next _set, which would report them as spuriously changed.
174
+ effectKeys = [];
101
175
  throw error;
102
176
  }
103
177
  }
104
178
  if (triggerChange) {
105
179
  try {
106
- control.trigger(ChangeEventName, [name, ...effectKeys]);
180
+ control.trigger(ChangeEventName, dedupe([name, ...effectKeys]));
107
181
  if (!control.isIntercepting()) {
108
182
  effectKeys = [];
109
183
  }
@@ -121,6 +195,9 @@ export function createStore(initialData = {}) {
121
195
  effectKeys = [];
122
196
  return true;
123
197
  }
198
+ // Clear before propagating (see the effect-trigger catch
199
+ // above): a leaked effectKeys would taint the next _set.
200
+ effectKeys = [];
124
201
  throw error;
125
202
  }
126
203
  }
@@ -129,7 +206,28 @@ export function createStore(initialData = {}) {
129
206
  return false;
130
207
  };
131
208
  function asyncSet(name, value) {
132
- setTimeout(() => {
209
+ // Validate computed keys synchronously, mirroring set(). Deferring the
210
+ // check to the timer callback would turn a misuse into an uncaught
211
+ // exception escaping the timer (crashing Node / surfacing as an uncaught
212
+ // browser error) instead of a catchable throw at the call site.
213
+ if (typeof name === "string") {
214
+ if (computedKeys.has(name)) {
215
+ throw new Error(`Cannot set computed property "${name}"`);
216
+ }
217
+ }
218
+ else if (typeof name === "object" && name !== null) {
219
+ for (const k of Object.keys(name)) {
220
+ if (computedKeys.has(k)) {
221
+ throw new Error(`Cannot set computed property "${k}"`);
222
+ }
223
+ }
224
+ }
225
+ const timer = setTimeout(() => {
226
+ pendingTimers.delete(timer);
227
+ // The store may have been destroyed between scheduling and firing.
228
+ if (destroyed) {
229
+ return;
230
+ }
133
231
  if (typeof name === "string") {
134
232
  set(name, value);
135
233
  }
@@ -137,65 +235,235 @@ export function createStore(initialData = {}) {
137
235
  set(name);
138
236
  }
139
237
  }, 0);
238
+ pendingTimers.add(timer);
140
239
  }
141
- function set(name, value) {
240
+ // Coalesce a change log per key and replay it as one onChange each, keeping
241
+ // the first entry's pre-cascade `prev` and the last entry's settled `value`
242
+ // and dropping keys whose net value is unchanged. Store-change errors route
243
+ // to the error event; an unhandled one propagates unless the surrounding
244
+ // callback already failed (`hasCallbackError`), in which case it is swallowed
245
+ // so the original callback error is the one that ultimately surfaces. Shared
246
+ // by batch() and the single-set cascade wrapper so both coalesce identically.
247
+ const replayCoalescedLog = (log, hasCallbackError) => {
248
+ var _a;
249
+ const coalesced = new Map();
250
+ for (const [propName, value, prev] of log) {
251
+ const existing = coalesced.get(propName);
252
+ if (existing) {
253
+ existing.value = value;
254
+ }
255
+ else {
256
+ coalesced.set(propName, { value, prev });
257
+ }
258
+ }
259
+ for (const [propName, { value, prev }] of coalesced) {
260
+ if (value === prev) {
261
+ continue;
262
+ }
263
+ const changeArgs = [
264
+ value,
265
+ prev,
266
+ ];
267
+ try {
268
+ changes.trigger(propName, ...changeArgs);
269
+ }
270
+ catch (error) {
271
+ control.trigger(ErrorEventName, {
272
+ error: error instanceof Error
273
+ ? error
274
+ : new Error(String(error)),
275
+ args: changeArgs,
276
+ type: "store-change",
277
+ name: propName,
278
+ });
279
+ if ((_a = control.get(ErrorEventName)) === null || _a === void 0 ? void 0 : _a.hasListener()) {
280
+ continue;
281
+ }
282
+ if (hasCallbackError) {
283
+ continue;
284
+ }
285
+ throw error;
286
+ }
287
+ }
288
+ };
289
+ // Replay a coalesced cascade log and, if the driving callback failed, rethrow
290
+ // its error last (after every surviving onChange has fired).
291
+ const replayCoalescedChanges = (log, hasCallbackError, callbackError) => {
292
+ replayCoalescedLog(log, hasCallbackError);
293
+ if (hasCallbackError) {
294
+ throw callbackError;
295
+ }
296
+ };
297
+ // Run `fn` with onChange emissions intercepted and coalesced. The control
298
+ // change-key collection (effectKeys / effectInterceptor ordering) inside
299
+ // `fn` is untouched, so only the per-key onChange stream is deduped.
300
+ const withChangeCoalescing = (fn, liveKey) => {
301
+ const log = [];
302
+ let liveDelivered = false;
303
+ const logger = function (propName, args) {
304
+ // Deliver the directly-set key's onChange live (it fires inside _set
305
+ // before the effect cascade) so plain onChange listeners still run
306
+ // ahead of effect listeners; only the cascade's emissions are
307
+ // coalesced. Returning a non-false value lets the interceptor pass
308
+ // the event through to its listeners.
309
+ if (!liveDelivered && liveKey !== undefined && propName === liveKey) {
310
+ liveDelivered = true;
311
+ return true;
312
+ }
313
+ log.push([propName, args[0], args[1]]);
314
+ return false;
315
+ };
316
+ changes.intercept(logger);
317
+ let callbackError;
318
+ let hasCallbackError = false;
319
+ try {
320
+ fn();
321
+ }
322
+ catch (error) {
323
+ callbackError = error;
324
+ hasCallbackError = true;
325
+ }
326
+ finally {
327
+ changes.stopIntercepting();
328
+ }
329
+ replayCoalescedChanges(log, hasCallbackError, callbackError);
330
+ };
331
+ // The write path shared by set() and its coalescing wrapper. Computed-key
332
+ // validation happens in set() before this runs.
333
+ const applySet = (name, value) => {
142
334
  var _a, _b;
143
335
  if (typeof name === "string") {
144
336
  _set(name, value);
337
+ return;
145
338
  }
146
- else if (typeof name === "object") {
147
- const changedKeys = [];
148
- const isIntercepting = control.isIntercepting();
149
- const hasEffectListener = (_a = control.get(EffectEventName)) === null || _a === void 0 ? void 0 : _a.hasListener();
150
- const shouldInterceptEffects = hasEffectListener && !isIntercepting;
151
- let controlError = null;
339
+ const changedKeys = [];
340
+ const isIntercepting = control.isIntercepting();
341
+ const hasEffectListener = (_a = control.get(EffectEventName)) === null || _a === void 0 ? void 0 : _a.hasListener();
342
+ const shouldInterceptEffects = hasEffectListener && !isIntercepting;
343
+ let controlError = null;
344
+ if (shouldInterceptEffects) {
345
+ control.intercept(effectInterceptor);
346
+ computedBatch = new Map();
347
+ }
348
+ let allChangedKeys = [];
349
+ try {
350
+ // Phase 1: write every base value with effect emission deferred,
351
+ // so computed values do not recompute against a half-updated
352
+ // state mid-loop.
152
353
  if (shouldInterceptEffects) {
153
- control.intercept(effectInterceptor);
354
+ deferEffects = true;
154
355
  }
155
- try {
156
- Object.entries(name).forEach(([k, v]) => {
157
- if (_set(k, v, false)) {
158
- changedKeys.push(k);
159
- }
160
- });
161
- const allChangedKeys = [
162
- ...changedKeys,
163
- ...effectKeys,
164
- ];
165
- if (allChangedKeys.length > 0) {
356
+ const entries = Object.entries(name);
357
+ entries.forEach(([k, v]) => {
358
+ if (_set(k, v, false)) {
359
+ changedKeys.push(k);
360
+ }
361
+ });
362
+ // Phase 2: with all base values final, replay the effect once per
363
+ // changed key. `computedBatch` skips a computed whose dependency
364
+ // values are unchanged since its last recompute, while still
365
+ // letting chained computeds re-settle when an upstream updates.
366
+ if (shouldInterceptEffects) {
367
+ deferEffects = false;
368
+ changedKeys.forEach((k) => {
369
+ var _a;
370
+ // Mirror _set's effect error contract: a throwing effect
371
+ // listener routes to the error event (and is swallowed if
372
+ // a handler exists) rather than aborting the whole set.
166
373
  try {
167
- control.trigger(ChangeEventName, allChangedKeys);
374
+ control.trigger(EffectEventName, k, data.get(k));
168
375
  }
169
376
  catch (error) {
170
- controlError = error instanceof Error
171
- ? error
172
- : new Error(String(error));
377
+ control.trigger(ErrorEventName, {
378
+ error: error instanceof Error
379
+ ? error
380
+ : new Error(String(error)),
381
+ args: [k],
382
+ type: "store-control",
383
+ name: k,
384
+ });
385
+ if (!((_a = control.get(ErrorEventName)) === null || _a === void 0 ? void 0 : _a.hasListener())) {
386
+ throw error;
387
+ }
173
388
  }
174
- }
389
+ });
175
390
  }
176
- finally {
177
- if (shouldInterceptEffects) {
178
- effectKeys = [];
179
- control.stopIntercepting();
180
- }
391
+ allChangedKeys = dedupe([
392
+ ...changedKeys,
393
+ ...effectKeys,
394
+ ]);
395
+ }
396
+ finally {
397
+ if (shouldInterceptEffects) {
398
+ effectKeys = [];
399
+ deferEffects = false;
400
+ computedBatch = null;
401
+ control.stopIntercepting();
181
402
  }
182
- if (controlError) {
183
- control.trigger(ErrorEventName, {
184
- error: controlError,
185
- args: [name],
186
- type: "store-control",
187
- });
188
- if ((_b = control.get(ErrorEventName)) === null || _b === void 0 ? void 0 : _b.hasListener()) {
189
- return;
403
+ }
404
+ // Fire the outer change AFTER intercepting stops; otherwise the
405
+ // effectInterceptor (active during the loop to fold computed/effect
406
+ // writes into effectKeys) would swallow this trigger too.
407
+ if (allChangedKeys.length > 0) {
408
+ try {
409
+ control.trigger(ChangeEventName, allChangedKeys);
410
+ }
411
+ catch (error) {
412
+ controlError = error instanceof Error
413
+ ? error
414
+ : new Error(String(error));
415
+ }
416
+ }
417
+ if (controlError) {
418
+ control.trigger(ErrorEventName, {
419
+ error: controlError,
420
+ args: [name],
421
+ type: "store-control",
422
+ });
423
+ if ((_b = control.get(ErrorEventName)) === null || _b === void 0 ? void 0 : _b.hasListener()) {
424
+ return;
425
+ }
426
+ throw controlError;
427
+ }
428
+ };
429
+ function set(name, value) {
430
+ assertAlive();
431
+ if (typeof name === "string") {
432
+ if (computedKeys.has(name)) {
433
+ throw new Error(`Cannot set computed property "${name}"`);
434
+ }
435
+ }
436
+ else if (typeof name === "object") {
437
+ // Validate all keys before any write so a computed key in the patch
438
+ // throws without partially applying the others.
439
+ for (const k of Object.keys(name)) {
440
+ if (computedKeys.has(k)) {
441
+ throw new Error(`Cannot set computed property "${k}"`);
190
442
  }
191
- throw controlError;
192
443
  }
193
444
  }
194
445
  else {
195
446
  throw new Error(`Invalid key: ${String(name)}`);
196
447
  }
448
+ // A cascade-triggering set coalesces its onChange emissions: a computed
449
+ // touched several times during the cascade (e.g. a diamond sink
450
+ // recomputed once per upstream) fires onChange once with its settled
451
+ // value and the real pre-set prev, instead of leaking an internally
452
+ // inconsistent intermediate (b_new + c_old) with a wrong prev. The
453
+ // control change-key collection is left untouched, so change-key
454
+ // ordering is unaffected.
455
+ if (canCoalesceCascade()) {
456
+ // For a single-key set, deliver that key's onChange live (before the
457
+ // effect cascade) and coalesce only the downstream computed-key
458
+ // emissions. A multi-key (object) set is an explicit batch, so all
459
+ // of its onChange emissions remain coalesced.
460
+ withChangeCoalescing(() => applySet(name, value), typeof name === "string" ? name : undefined);
461
+ return;
462
+ }
463
+ applySet(name, value);
197
464
  }
198
465
  const get = (key) => {
466
+ assertAlive();
199
467
  if (typeof key === "string") {
200
468
  const value = data.get(key);
201
469
  return value;
@@ -223,7 +491,7 @@ export function createStore(initialData = {}) {
223
491
  };
224
492
  let batching = false;
225
493
  const batch = (fn) => {
226
- var _a, _b;
494
+ var _a;
227
495
  if (batching) {
228
496
  throw new Error("Nested batch() calls are not supported");
229
497
  }
@@ -257,45 +525,30 @@ export function createStore(initialData = {}) {
257
525
  changes.stopIntercepting();
258
526
  batching = false;
259
527
  }
260
- for (const [propName, value, prev] of log) {
261
- const changeArgs = [
262
- value,
263
- prev,
264
- ];
528
+ // Coalesce the log per key before replaying: a key written multiple
529
+ // times in the batch (e.g. a computed recomputing once per base-key
530
+ // write) must fire onChange once with its final value, not once per
531
+ // intermediate value. Drop keys whose net value is unchanged from before
532
+ // the batch. The callback error (if any) is deferred and rethrown at the
533
+ // end so the partial-write control change event below still fires.
534
+ replayCoalescedLog(log, hasCallbackError);
535
+ // Dedupe so the control change event lists each key once, matching the
536
+ // non-batch path (which dedupes via `dedupe([name, ...effectKeys])`).
537
+ // A computed touched by several base-key writes would otherwise repeat.
538
+ const dedupedChangedKeys = dedupe(allChangedKeys);
539
+ if (dedupedChangedKeys.length > 0) {
265
540
  try {
266
- changes.trigger(propName, ...changeArgs);
541
+ control.trigger(ChangeEventName, dedupedChangedKeys);
267
542
  }
268
543
  catch (error) {
269
544
  control.trigger(ErrorEventName, {
270
545
  error: error instanceof Error
271
546
  ? error
272
547
  : new Error(String(error)),
273
- args: changeArgs,
274
- type: "store-change",
275
- name: propName,
276
- });
277
- if ((_a = control.get(ErrorEventName)) === null || _a === void 0 ? void 0 : _a.hasListener()) {
278
- continue;
279
- }
280
- if (hasCallbackError) {
281
- continue;
282
- }
283
- throw error;
284
- }
285
- }
286
- if (allChangedKeys.length > 0) {
287
- try {
288
- control.trigger(ChangeEventName, allChangedKeys);
289
- }
290
- catch (error) {
291
- control.trigger(ErrorEventName, {
292
- error: error instanceof Error
293
- ? error
294
- : new Error(String(error)),
295
- args: [allChangedKeys],
548
+ args: [dedupedChangedKeys],
296
549
  type: "store-control",
297
550
  });
298
- if ((_b = control.get(ErrorEventName)) === null || _b === void 0 ? void 0 : _b.hasListener()) {
551
+ if ((_a = control.get(ErrorEventName)) === null || _a === void 0 ? void 0 : _a.hasListener()) {
299
552
  if (hasCallbackError) {
300
553
  throw callbackError;
301
554
  }
@@ -313,16 +566,125 @@ export function createStore(initialData = {}) {
313
566
  };
314
567
  const reset = () => {
315
568
  data.clear();
569
+ // Recompute computed keys from the cleared deps (registration order, so a
570
+ // base computed re-seeds before a dependent in a chain) and re-seed them
571
+ // silently, keeping each consistent with fn(deps) instead of stale.
572
+ computedReseeders.forEach((reseed) => {
573
+ reseed();
574
+ });
316
575
  control.trigger(ResetEventName);
317
576
  };
577
+ // One-call teardown: destroy the underlying change/pipe/control buses and
578
+ // drop all data. Post-destroy set/get throw rather than silently no-op.
579
+ const destroy = () => {
580
+ pendingTimers.forEach((timer) => clearTimeout(timer));
581
+ pendingTimers.clear();
582
+ changes.destroy();
583
+ pipe.destroy();
584
+ control.destroy();
585
+ data.clear();
586
+ computedKeys.clear();
587
+ computingKeys.clear();
588
+ computedReseeders.clear();
589
+ computedEffectListeners.clear();
590
+ effectKeys = [];
591
+ destroyed = true;
592
+ };
593
+ const isDestroyed = () => destroyed;
594
+ // Registers `key` as a derived value recomputed from `deps`. Built as sugar
595
+ // over the `effect` control event: recompute writes via the internal `_set`
596
+ // (triggerChange = true) so the change folds into the same outer `change`
597
+ // batch via `effectKeys`. Computed keys flow transparently through
598
+ // get/getData/onChange/useStoreState/useStoreSelector.
599
+ //
600
+ // Recompute is registration-order, not topologically sorted, so a dependent
601
+ // (chain or diamond) may recompute from a stale upstream first. The internal
602
+ // intermediate recompute is invisible to consumers: set() coalesces the
603
+ // onChange stream for a cascade, so each computed fires onChange once with
604
+ // its settled value and the real pre-set prev (`computedBatch` also dedupes
605
+ // redundant recomputes within a multi-key set). The final get()/onChange
606
+ // value is always correct.
607
+ const computed = (key, deps, fn) => {
608
+ // Bail before running user code or seeding data: on a destroyed store
609
+ // the control bus throws only when the recompute listener is attached,
610
+ // by which point fn() has already run and the initial value has been
611
+ // written, repopulating cleared state that getData() would then expose.
612
+ assertAlive();
613
+ const readDeps = () => deps.map((d) => data.get(d));
614
+ // Compute the initial value BEFORE committing any registration state.
615
+ // If `fn` throws here, nothing has been mutated, so the key does not
616
+ // become a permanently read-only computed with no listener installed.
617
+ const initialValue = fn(...readDeps());
618
+ // Seed silently (no change emitted at setup time) but through pipe, so
619
+ // the value read right after computed()/reset() matches what every
620
+ // later _set-driven recompute produces; otherwise a piped key silently
621
+ // changes shape on the first dependency change. beforeChange is skipped
622
+ // on purpose: a silent seed has no change to veto, and a computed key
623
+ // must always hold a value.
624
+ const seed = (raw) => {
625
+ const pipeArgs = [raw];
626
+ const piped = pipe.pipe(key, ...pipeArgs);
627
+ data.set(key, piped !== undefined ? piped : raw);
628
+ };
629
+ seed(initialValue);
630
+ computedKeys.add(key);
631
+ // reset() re-runs this to recompute the value from the cleared deps. It
632
+ // seeds `data` silently (no change emitted), matching this setup path.
633
+ computedReseeders.set(key, () => {
634
+ seed(fn(...readDeps()));
635
+ });
636
+ // Detach a prior recompute closure for this key (re-registration)
637
+ // before installing the new one, so the old fn does not keep running on
638
+ // every dependency change.
639
+ const prevEffect = computedEffectListeners.get(key);
640
+ if (prevEffect) {
641
+ control.removeListener(EffectEventName, prevEffect);
642
+ }
643
+ const effectListener = (changedName) => {
644
+ if (deps.indexOf(changedName) === -1) {
645
+ return;
646
+ }
647
+ const depValues = readDeps();
648
+ // Within a single multi-key set, skip the recompute only when this
649
+ // computed's dependency values are unchanged since its last
650
+ // recompute in the batch. That dedupes redundant dep changes while
651
+ // still re-settling a chain when an upstream computed updates.
652
+ if (computedBatch) {
653
+ const lastDepValues = computedBatch.get(key);
654
+ if (lastDepValues
655
+ && arraysShallowEqual(lastDepValues, depValues)) {
656
+ return;
657
+ }
658
+ computedBatch.set(key, depValues);
659
+ }
660
+ if (computingKeys.has(key)) {
661
+ throw new Error(`Cyclic computed dependency detected at "${key}"`);
662
+ }
663
+ computingKeys.add(key);
664
+ try {
665
+ // runBeforeChange=false: a computed key is derived and must
666
+ // always hold fn(deps); a beforeChange veto here would strand a
667
+ // stale value (see _set and the seed() rationale).
668
+ _set(key, fn(...depValues), true, false);
669
+ }
670
+ finally {
671
+ computingKeys.delete(key);
672
+ }
673
+ };
674
+ control.addListener(EffectEventName, effectListener);
675
+ computedEffectListeners.set(key, effectListener);
676
+ };
318
677
  const api = {
319
678
  set,
320
679
  get,
321
680
  getData,
322
681
  batch,
323
682
  asyncSet,
683
+ computed,
324
684
  isEmpty,
325
685
  reset,
686
+ destroy,
687
+ isDestroyed,
326
688
  onChange: changes.addListener,
327
689
  removeOnChange: changes.removeListener,
328
690
  updateOnChangeOptions: changes.updateListenerOptions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuindji/reactive",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "author": "Ivan Kuindzhi",
5
5
  "type": "module",
6
6
  "repository": {