@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/store.js CHANGED
@@ -10,11 +10,74 @@ 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) => {
@@ -98,12 +161,16 @@ export function createStore(initialData = {}) {
98
161
  effectKeys = [];
99
162
  return true;
100
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 = [];
101
168
  throw error;
102
169
  }
103
170
  }
104
171
  if (triggerChange) {
105
172
  try {
106
- control.trigger(ChangeEventName, [name, ...effectKeys]);
173
+ control.trigger(ChangeEventName, dedupe([name, ...effectKeys]));
107
174
  if (!control.isIntercepting()) {
108
175
  effectKeys = [];
109
176
  }
@@ -121,6 +188,9 @@ export function createStore(initialData = {}) {
121
188
  effectKeys = [];
122
189
  return true;
123
190
  }
191
+ // Clear before propagating (see the effect-trigger catch
192
+ // above): a leaked effectKeys would taint the next _set.
193
+ effectKeys = [];
124
194
  throw error;
125
195
  }
126
196
  }
@@ -129,7 +199,12 @@ export function createStore(initialData = {}) {
129
199
  return false;
130
200
  };
131
201
  function asyncSet(name, value) {
132
- 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
+ }
133
208
  if (typeof name === "string") {
134
209
  set(name, value);
135
210
  }
@@ -137,65 +212,228 @@ export function createStore(initialData = {}) {
137
212
  set(name);
138
213
  }
139
214
  }, 0);
215
+ pendingTimers.add(timer);
140
216
  }
141
- 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) => {
142
304
  var _a, _b;
143
305
  if (typeof name === "string") {
144
306
  _set(name, value);
307
+ return;
145
308
  }
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;
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.
152
323
  if (shouldInterceptEffects) {
153
- control.intercept(effectInterceptor);
324
+ deferEffects = true;
154
325
  }
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) {
326
+ const entries = Object.entries(name);
327
+ entries.forEach(([k, v]) => {
328
+ if (_set(k, v, false)) {
329
+ changedKeys.push(k);
330
+ }
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.
166
343
  try {
167
- control.trigger(ChangeEventName, allChangedKeys);
344
+ control.trigger(EffectEventName, k, data.get(k));
168
345
  }
169
346
  catch (error) {
170
- controlError = error instanceof Error
171
- ? error
172
- : new Error(String(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
+ }
173
358
  }
174
- }
359
+ });
175
360
  }
176
- finally {
177
- if (shouldInterceptEffects) {
178
- effectKeys = [];
179
- control.stopIntercepting();
180
- }
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();
181
372
  }
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;
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) {
378
+ try {
379
+ control.trigger(ChangeEventName, allChangedKeys);
380
+ }
381
+ catch (error) {
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}"`);
190
412
  }
191
- throw controlError;
192
413
  }
193
414
  }
194
415
  else {
195
416
  throw new Error(`Invalid key: ${String(name)}`);
196
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);
197
434
  }
198
435
  const get = (key) => {
436
+ assertAlive();
199
437
  if (typeof key === "string") {
200
438
  const value = data.get(key);
201
439
  return value;
@@ -257,7 +495,26 @@ export function createStore(initialData = {}) {
257
495
  changes.stopIntercepting();
258
496
  batching = false;
259
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();
260
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
+ }
261
518
  const changeArgs = [
262
519
  value,
263
520
  prev,
@@ -283,16 +540,20 @@ export function createStore(initialData = {}) {
283
540
  throw error;
284
541
  }
285
542
  }
286
- if (allChangedKeys.length > 0) {
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) {
287
548
  try {
288
- control.trigger(ChangeEventName, allChangedKeys);
549
+ control.trigger(ChangeEventName, dedupedChangedKeys);
289
550
  }
290
551
  catch (error) {
291
552
  control.trigger(ErrorEventName, {
292
553
  error: error instanceof Error
293
554
  ? error
294
555
  : new Error(String(error)),
295
- args: [allChangedKeys],
556
+ args: [dedupedChangedKeys],
296
557
  type: "store-control",
297
558
  });
298
559
  if ((_b = control.get(ErrorEventName)) === null || _b === void 0 ? void 0 : _b.hasListener()) {
@@ -313,16 +574,122 @@ export function createStore(initialData = {}) {
313
574
  };
314
575
  const reset = () => {
315
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
+ });
316
583
  control.trigger(ResetEventName);
317
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
+ };
318
682
  const api = {
319
683
  set,
320
684
  get,
321
685
  getData,
322
686
  batch,
323
687
  asyncSet,
688
+ computed,
324
689
  isEmpty,
325
690
  reset,
691
+ destroy,
692
+ isDestroyed,
326
693
  onChange: changes.addListener,
327
694
  removeOnChange: changes.removeListener,
328
695
  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.2.0",
4
4
  "author": "Ivan Kuindzhi",
5
5
  "type": "module",
6
6
  "repository": {