@esportsplus/reactivity 0.38.1 → 0.39.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.
@@ -0,0 +1,212 @@
1
+ import { test } from 'vitest';
2
+ import { computed, dispose, effect, flush, onCleanup, read, root, signal, write } from '~/system';
3
+ import type { Computed } from '~/system';
4
+
5
+
6
+ const CHURN = 1000;
7
+
8
+
9
+ function noop() {
10
+ }
11
+
12
+ // Owner registrations still held after churn: user cleanups plus owned children (`owned` is absent
13
+ // before the owned list existed, so this also counts a baseline tree's closure array)
14
+ function retained(node: Computed<unknown>): number {
15
+ let n = node.cleanup === null ? 0 : typeof node.cleanup === 'function' ? 1 : node.cleanup.length;
16
+
17
+ for (let child = node.owned; child; child = child.nextOwned) {
18
+ n++;
19
+ }
20
+
21
+ return n;
22
+ }
23
+
24
+
25
+ test('ownership churn', async ({ bench }) => {
26
+ await bench.compare(
27
+ bench(`${CHURN} child roots created + disposed under one owner`, () => {
28
+ let owner = computed(() => {
29
+ for (let i = 0; i < CHURN; i++) {
30
+ root((d) => {
31
+ onCleanup(noop);
32
+ d();
33
+ });
34
+ }
35
+
36
+ return 0;
37
+ });
38
+
39
+ dispose(owner);
40
+ }),
41
+ bench(`${CHURN} child effects created + disposed under one owner`, () => {
42
+ let owner = computed(() => {
43
+ for (let i = 0; i < CHURN; i++) {
44
+ effect(() => {
45
+ onCleanup(noop);
46
+ })();
47
+ }
48
+
49
+ return 0;
50
+ });
51
+
52
+ dispose(owner);
53
+ })
54
+ );
55
+ });
56
+
57
+
58
+ test('ownership churn retention', () => {
59
+ let gc = (globalThis as { gc?: () => void }).gc,
60
+ heap = 0;
61
+
62
+ for (let n of [1000, 10000]) {
63
+ gc?.();
64
+
65
+ let before = process.memoryUsage().heapUsed,
66
+ owner = computed(() => {
67
+ for (let i = 0; i < n; i++) {
68
+ root((d) => {
69
+ onCleanup(noop);
70
+ d();
71
+ });
72
+ }
73
+
74
+ return 0;
75
+ });
76
+
77
+ gc?.();
78
+ heap = process.memoryUsage().heapUsed - before;
79
+
80
+ console.log(`[ownership] churn ${n}: retained registrations = ${retained(owner as Computed<unknown>)}, heap delta = ${gc ? `${(heap / 1024).toFixed(1)} KiB` : 'n/a (run with --expose-gc)'}`);
81
+ dispose(owner);
82
+ }
83
+ });
84
+
85
+
86
+ test('ownership effect re-run', async ({ bench }) => {
87
+ let s = signal(0),
88
+ nested = signal(0),
89
+ i = 0;
90
+
91
+ let stop = effect(() => {
92
+ read(s);
93
+
94
+ for (let j = 0; j < 10; j++) {
95
+ effect(() => {
96
+ read(nested);
97
+ onCleanup(noop);
98
+ });
99
+ }
100
+ });
101
+
102
+ await bench.compare(
103
+ bench('re-run effect owning 10 child effects', () => {
104
+ write(s, ++i);
105
+ flush();
106
+ }),
107
+ bench('re-run 10 child effects (parent stable)', () => {
108
+ write(nested, ++i);
109
+ flush();
110
+ })
111
+ );
112
+
113
+ stop();
114
+ });
115
+
116
+
117
+ test('ownership tree dispose', async ({ bench }) => {
118
+ await bench.compare(
119
+ bench('deep owner tree (1000 nested roots) disposed at once', () => {
120
+ root((d) => {
121
+ let parent = (depth: number): void => {
122
+ if (depth === 0) {
123
+ return;
124
+ }
125
+
126
+ root(() => {
127
+ onCleanup(noop);
128
+ parent(depth - 1);
129
+ });
130
+ };
131
+
132
+ parent(1000);
133
+ d();
134
+ });
135
+ }),
136
+ bench('deep owner tree (200 nested effects) disposed at once', () => {
137
+ root((d) => {
138
+ let parent = (depth: number): void => {
139
+ if (depth === 0) {
140
+ return;
141
+ }
142
+
143
+ effect(() => {
144
+ onCleanup(noop);
145
+ parent(depth - 1);
146
+ });
147
+ };
148
+
149
+ parent(200);
150
+ d();
151
+ });
152
+ }),
153
+ bench('wide owner tree (1000 child effects) disposed at once', () => {
154
+ root((d) => {
155
+ for (let i = 0; i < 1000; i++) {
156
+ effect(() => {
157
+ onCleanup(noop);
158
+ });
159
+ }
160
+
161
+ d();
162
+ });
163
+ }),
164
+ bench('wide x deep owner tree (32 x 32 effects) disposed at once', () => {
165
+ root((d) => {
166
+ for (let i = 0; i < 32; i++) {
167
+ effect(() => {
168
+ for (let j = 0; j < 32; j++) {
169
+ effect(() => {
170
+ onCleanup(noop);
171
+ });
172
+ }
173
+ });
174
+ }
175
+
176
+ d();
177
+ });
178
+ })
179
+ );
180
+ });
181
+
182
+
183
+ test('ownership single cleanup', async ({ bench }) => {
184
+ let s = signal(0),
185
+ i = 0;
186
+
187
+ let stop = effect(() => {
188
+ read(s);
189
+ onCleanup(noop);
190
+ });
191
+
192
+ await bench.compare(
193
+ bench('effect re-run with 1 cleanup', () => {
194
+ write(s, ++i);
195
+ flush();
196
+ }),
197
+ bench('computed create + dispose with 1 cleanup', () => {
198
+ dispose(computed(() => {
199
+ onCleanup(noop);
200
+ return 0;
201
+ }));
202
+ }),
203
+ bench('root create + dispose with 1 cleanup', () => {
204
+ root((d) => {
205
+ onCleanup(noop);
206
+ d();
207
+ });
208
+ })
209
+ );
210
+
211
+ stop();
212
+ });
@@ -50,8 +50,9 @@ declare class ReactiveArray<T> extends Array<T> {
50
50
  concat(...items: (T | ConcatArray<T>)[]): ReactiveArray<T>;
51
51
  dispatch<K extends keyof Events<T>>(event: K, value?: Events<T>[K]): void;
52
52
  dispose(): void;
53
- on<K extends keyof Events<T>>(event: K, listener: Listener<Events<T>[K]>): void;
54
- once<K extends keyof Events<T>>(event: K, listener: Listener<Events<T>[K]>): void;
53
+ on<K extends keyof Events<T>>(event: K, listener: Listener<Events<T>[K]>): VoidFunction;
54
+ once<K extends keyof Events<T>>(event: K, listener: Listener<Events<T>[K]>): VoidFunction;
55
+ private prune;
55
56
  pop(): T | undefined;
56
57
  push(...items: T[]): number;
57
58
  reverse(): this;
@@ -77,7 +77,7 @@ class ReactiveArray extends Array {
77
77
  let dirty = false, errors = null;
78
78
  for (let i = 0, n = listeners.length; i < n; i++) {
79
79
  let listener = listeners[i];
80
- if (listener === null) {
80
+ if (listener == null) {
81
81
  continue;
82
82
  }
83
83
  try {
@@ -94,9 +94,7 @@ class ReactiveArray extends Array {
94
94
  }
95
95
  }
96
96
  if (dirty) {
97
- while (listeners.length && listeners[listeners.length - 1] === null) {
98
- listeners.pop();
99
- }
97
+ this.prune(event, listeners);
100
98
  }
101
99
  if (errors !== null) {
102
100
  let error = errors.length === 1
@@ -119,25 +117,45 @@ class ReactiveArray extends Array {
119
117
  this.listeners[event] = [entry];
120
118
  }
121
119
  else {
122
- let hole = listeners.length;
120
+ let hole = listeners.length, registered = false;
123
121
  for (let i = 0, n = hole; i < n; i++) {
124
122
  let l = listeners[i];
125
123
  if (l === entry) {
126
- return;
124
+ registered = true;
125
+ break;
127
126
  }
128
127
  else if (l === null && hole === n) {
129
128
  hole = i;
130
129
  }
131
130
  }
132
- listeners[hole] = entry;
133
- while (listeners.length && listeners[listeners.length - 1] === null) {
134
- listeners.pop();
131
+ if (!registered) {
132
+ listeners[hole] = entry;
133
+ this.prune(event, listeners);
135
134
  }
136
135
  }
136
+ return () => {
137
+ let current = this.listeners[event];
138
+ if (current === undefined) {
139
+ return;
140
+ }
141
+ let i = current.indexOf(entry);
142
+ if (i !== -1) {
143
+ current[i] = null;
144
+ this.prune(event, current);
145
+ }
146
+ };
137
147
  }
138
148
  once(event, listener) {
139
149
  listener.once = true;
140
- this.on(event, listener);
150
+ return this.on(event, listener);
151
+ }
152
+ prune(event, listeners) {
153
+ while (listeners.length && listeners[listeners.length - 1] === null) {
154
+ listeners.pop();
155
+ }
156
+ if (listeners.length === 0 && this.listeners[event] === listeners) {
157
+ delete this.listeners[event];
158
+ }
141
159
  }
142
160
  pop() {
143
161
  if (this.length === 0) {
@@ -1,6 +1,6 @@
1
- import { onCleanup, root } from '@esportsplus/reactivity';
2
1
  import { isArray, isObject } from '@esportsplus/utilities';
3
2
  import { PACKAGE_NAME } from '../constants.js';
3
+ import { onCleanup, root } from '../system.js';
4
4
  import { ReactiveArray } from './array.js';
5
5
  import { ReactiveObject } from './object.js';
6
6
  function reactive(input) {
package/build/system.d.ts CHANGED
@@ -7,6 +7,7 @@ declare namespace computed {
7
7
  declare const dispose: <T>(computed: Computed<T>) => void;
8
8
  declare const effect: <T>(fn: Computed<T>['fn'], apply?: (value: T, prev: T | undefined) => void) => () => void;
9
9
  declare const flush: () => void;
10
+ declare const hasOwner: () => boolean;
10
11
  declare const isComputed: (value: unknown) => value is Computed<unknown>;
11
12
  declare const isSignal: (value: unknown) => value is Signal<unknown>;
12
13
  declare const onCleanup: (fn: VoidFunction) => typeof fn;
@@ -19,5 +20,5 @@ declare namespace signal {
19
20
  }
20
21
  declare const untrack: <T>(fn: () => T) => T;
21
22
  declare const write: <T>(signal: Signal<T>, value: T) => void;
22
- export { batch, computed, dispose, effect, flush, isComputed, isSignal, onCleanup, peek, read, root, signal, untrack, write };
23
+ export { batch, computed, dispose, effect, flush, hasOwner, isComputed, isSignal, onCleanup, peek, read, root, signal, untrack, write };
23
24
  export type { Computed, Signal };
package/build/system.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { PACKAGE_NAME, SIGNAL, STABILIZER_DEFERRED, STABILIZER_IDLE, STABILIZER_RESCHEDULE, STABILIZER_RUNNING, STABILIZER_SCHEDULED, STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING } from './constants.js';
2
2
  import { isObject, isPromise } from '@esportsplus/utilities';
3
- let asyncMeta = new WeakMap(), depth = 0, disposeHead = null, draining = false, heap = new Array(64), heap_i = 0, heap_n = 0, linkPoolHead = null, microtask = queueMicrotask, notified = false, observer = null, pendingHead = null, pulled = null, puller = null, scope = null, stabilizer = STABILIZER_IDLE, version = 0, walkPoolHead = null, writes = 0;
3
+ let asyncFactory = new WeakMap(), depth = 0, disposeHead = null, draining = false, heap = new Array(64), heap_i = 0, heap_n = 0, linkPoolHead = null, microtask = queueMicrotask, notified = false, observer = null, pendingHead = null, pulled = null, puller = null, scope = null, stabilizer = STABILIZER_IDLE, version = 0, walkPoolHead = null, writes = 0;
4
4
  function noop() {
5
5
  }
6
6
  function walkPop(walk) {
@@ -23,30 +23,33 @@ function walkPush(computed, link, prev) {
23
23
  return { computed, link, prev };
24
24
  }
25
25
  function cleanup(computed) {
26
- if (!computed.cleanup) {
26
+ let child = computed.owned, value = computed.cleanup;
27
+ computed.cleanup = null;
28
+ if (child === null && typeof value === 'function') {
29
+ value();
27
30
  return;
28
31
  }
29
- let errors = [], value = computed.cleanup;
30
- computed.cleanup = null;
31
- if (typeof value === 'function') {
32
- try {
33
- value();
34
- }
35
- catch (e) {
36
- errors.push(e);
32
+ let errors = null, n = value === null ? 0 : typeof value === 'function' ? 1 : value.length;
33
+ for (let i = 0; i <= n; i++) {
34
+ while ((child = computed.owned) !== null && (i === n || child.ownerSlot <= i)) {
35
+ disown(child);
36
+ try {
37
+ dispose(child);
38
+ }
39
+ catch (e) {
40
+ (errors ??= []).push(e);
41
+ }
37
42
  }
38
- }
39
- else {
40
- for (let i = 0, n = value.length; i < n; i++) {
43
+ if (i < n) {
41
44
  try {
42
- value[i]();
45
+ (typeof value === 'function' ? value : value[i])();
43
46
  }
44
47
  catch (e) {
45
- errors.push(e);
48
+ (errors ??= []).push(e);
46
49
  }
47
50
  }
48
51
  }
49
- if (errors.length) {
52
+ if (errors !== null) {
50
53
  throw errors.length === 1 ? errors[0] : new AggregateError(errors, `${PACKAGE_NAME}: cleanup produced multiple errors`);
51
54
  }
52
55
  }
@@ -73,6 +76,20 @@ function deleteFromHeap(computed) {
73
76
  computed.nextHeap = undefined;
74
77
  computed.prevHeap = computed;
75
78
  }
79
+ function disown(child) {
80
+ let next = child.nextOwned, owner = child.owner, prev = child.prevOwned;
81
+ if (owner.owned === child) {
82
+ owner.owned = next;
83
+ if (next !== null) {
84
+ next.prevOwned = prev;
85
+ }
86
+ }
87
+ else {
88
+ prev.nextOwned = next;
89
+ (next ?? owner.owned).prevOwned = prev;
90
+ }
91
+ child.nextOwned = child.owner = child.prevOwned = null;
92
+ }
76
93
  function drainPending() {
77
94
  let node = pendingHead;
78
95
  pendingHead = null;
@@ -198,6 +215,21 @@ function notify(computed, newState) {
198
215
  stack = walkPop(stack);
199
216
  }
200
217
  }
218
+ function own(owner, child) {
219
+ let head = owner.owned, value = owner.cleanup;
220
+ child.owner = owner;
221
+ child.ownerSlot = value === null ? 0 : typeof value === 'function' ? 1 : value.length;
222
+ if (head === null) {
223
+ owner.owned = child;
224
+ child.prevOwned = child;
225
+ }
226
+ else {
227
+ let tail = head.prevOwned;
228
+ tail.nextOwned = child;
229
+ child.prevOwned = tail;
230
+ head.prevOwned = child;
231
+ }
232
+ }
201
233
  function pull(node) {
202
234
  if (!notified) {
203
235
  notified = true;
@@ -238,7 +270,7 @@ function recompute(computed) {
238
270
  if (computed.state & STATE_IN_HEAP) {
239
271
  deleteFromHeap(computed);
240
272
  }
241
- if (computed.cleanup) {
273
+ if (computed.cleanup !== null || computed.owned !== null) {
242
274
  try {
243
275
  cleanup(computed);
244
276
  }
@@ -486,7 +518,7 @@ function makeAsyncComputed(factory) {
486
518
  return read(node);
487
519
  });
488
520
  wrapper.pending = pending;
489
- asyncMeta.set(wrapper, { factory: factory });
521
+ asyncFactory.set(wrapper, factory);
490
522
  wrapper.disposal = stop;
491
523
  return wrapper;
492
524
  }
@@ -507,12 +539,12 @@ function makeComputed(fn, eager = false) {
507
539
  schedule();
508
540
  }
509
541
  link(self, observer);
510
- onCleanup(() => dispose(self));
542
+ own(observer, self);
511
543
  }
512
544
  else {
513
545
  recompute(self);
514
546
  if (scope) {
515
- onCleanup(() => dispose(self));
547
+ own(scope, self);
516
548
  }
517
549
  }
518
550
  return self;
@@ -529,8 +561,13 @@ function makeNode(fn) {
529
561
  gv: 0,
530
562
  height: 0,
531
563
  nextHeap: undefined,
564
+ nextOwned: null,
565
+ owned: null,
566
+ owner: null,
567
+ ownerSlot: 0,
532
568
  pending: null,
533
569
  prevHeap: null,
570
+ prevOwned: null,
534
571
  rv: 0,
535
572
  state: STATE_COMPUTED,
536
573
  subs: null,
@@ -556,18 +593,19 @@ const batch = (fn) => {
556
593
  const computed = (fn, equals = null) => {
557
594
  let o = observer, self = makeComputed(fn, true), value = self.value;
558
595
  if (isPromise(value) || (value != null && typeof value[Symbol.asyncIterator] === 'function')) {
559
- self = untrack(() => makeAsyncComputed(self));
560
- if (o) {
561
- onCleanup(() => dispose(self));
596
+ let owner = o || scope;
597
+ self = root(() => makeAsyncComputed(self));
598
+ if (owner !== null) {
599
+ own(owner, self);
562
600
  }
563
601
  }
564
602
  self.equals = equals;
565
603
  return self;
566
604
  };
567
605
  computed.invalidate = (c) => {
568
- let meta = asyncMeta.get(c);
569
- if (meta) {
570
- computed.invalidate(meta.factory);
606
+ let factory = asyncFactory.get(c);
607
+ if (factory) {
608
+ computed.invalidate(factory);
571
609
  return;
572
610
  }
573
611
  writes++;
@@ -581,7 +619,7 @@ const dispose = (computed) => {
581
619
  return;
582
620
  }
583
621
  draining = true;
584
- let node = computed;
622
+ let errors = null, node = computed;
585
623
  try {
586
624
  for (;;) {
587
625
  deleteFromHeap(node);
@@ -590,13 +628,26 @@ const dispose = (computed) => {
590
628
  dep = unlink(dep);
591
629
  }
592
630
  node.deps = null;
593
- if (node.cleanup) {
594
- cleanup(node);
631
+ if (node.owner !== null) {
632
+ disown(node);
633
+ }
634
+ if (node.cleanup !== null || node.owned !== null) {
635
+ try {
636
+ cleanup(node);
637
+ }
638
+ catch (e) {
639
+ (errors ??= []).push(e);
640
+ }
595
641
  }
596
642
  if (node.disposal) {
597
643
  let d = node.disposal;
598
644
  node.disposal = null;
599
- d();
645
+ try {
646
+ d();
647
+ }
648
+ catch (e) {
649
+ (errors ??= []).push(e);
650
+ }
600
651
  }
601
652
  if (disposeHead === null) {
602
653
  break;
@@ -608,6 +659,9 @@ const dispose = (computed) => {
608
659
  finally {
609
660
  draining = false;
610
661
  }
662
+ if (errors !== null) {
663
+ throw errors.length === 1 ? errors[0] : new AggregateError(errors, `${PACKAGE_NAME}: dispose produced multiple errors`);
664
+ }
611
665
  };
612
666
  const effect = (fn, apply) => {
613
667
  let prev;
@@ -635,6 +689,7 @@ const flush = () => {
635
689
  stabilize();
636
690
  }
637
691
  };
692
+ const hasOwner = () => observer !== null || scope !== null;
638
693
  const isComputed = (value) => {
639
694
  return isObject(value) && !!(value.state & STATE_COMPUTED);
640
695
  };
@@ -697,12 +752,16 @@ const read = (node) => {
697
752
  return node.value;
698
753
  };
699
754
  const root = (fn) => {
700
- let c, o = observer, s = scope, self = null, tracking = fn.length, value;
755
+ let o = observer, s = scope, tracking = fn.length, value;
701
756
  observer = null;
702
757
  try {
703
758
  if (tracking) {
704
- scope = self = makeNode(noop);
705
- value = fn(c = () => dispose(self));
759
+ let self = makeNode(noop), owner = o || s;
760
+ if (owner !== null) {
761
+ own(owner, self);
762
+ }
763
+ scope = self;
764
+ value = fn(() => dispose(self));
706
765
  }
707
766
  else {
708
767
  scope = null;
@@ -713,9 +772,6 @@ const root = (fn) => {
713
772
  observer = o;
714
773
  scope = s;
715
774
  }
716
- if (c) {
717
- onCleanup(c);
718
- }
719
775
  return value;
720
776
  };
721
777
  const signal = (value, equals = null) => {
@@ -792,4 +848,4 @@ const write = (signal, value) => {
792
848
  }
793
849
  schedule();
794
850
  };
795
- export { batch, computed, dispose, effect, flush, isComputed, isSignal, onCleanup, peek, read, root, signal, untrack, write };
851
+ export { batch, computed, dispose, effect, flush, hasOwner, isComputed, isSignal, onCleanup, peek, read, root, signal, untrack, write };
package/build/types.d.ts CHANGED
@@ -11,8 +11,13 @@ interface Computed<T> {
11
11
  gv: number;
12
12
  height: number;
13
13
  nextHeap: Computed<unknown> | undefined;
14
+ nextOwned: Computed<unknown> | null;
15
+ owned: Computed<unknown> | null;
16
+ owner: Computed<unknown> | null;
17
+ ownerSlot: number;
14
18
  pending: Signal<boolean> | null;
15
19
  prevHeap: Computed<unknown>;
20
+ prevOwned: Computed<unknown> | null;
16
21
  rv: number;
17
22
  state: number;
18
23
  subs: Link | null;
package/package.json CHANGED
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "type": "module",
38
38
  "types": "build/index.d.ts",
39
- "version": "0.38.1",
39
+ "version": "0.39.0",
40
40
  "scripts": {
41
41
  "agent:bench": "vitest bench --run",
42
42
  "agent:test": "tsc --noEmit && vitest run",