@esportsplus/reactivity 0.39.1 → 0.40.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/README.md CHANGED
@@ -222,7 +222,8 @@ let user = new ReactiveObject_1();
222
222
  | `reactive({...})` | Creates a reactive object with signals and computeds |
223
223
  | `reactive([...])` | Creates a reactive array |
224
224
  | `effect(fn, apply?)` | Runs a function that re-executes when dependencies change. Returns a dispose function. Optional `apply(value, prev)` runs untracked after each run with `fn`'s return value |
225
- | `root(fn)` | Creates an untracked scope. If `fn` accepts an argument, a dispose function is provided |
225
+ | `root(fn, owner?)` | Creates an untracked scope. If `fn` accepts an argument, a dispose function is provided. With `owner`, the scope belongs to that node instead of whatever is running: `root(() => …, owner)` runs `fn` owned by `owner` (to re-enter it after an `await`), `root((dispose) => …, owner)` creates a child root of `owner`. Reads stay untracked. Throws on a disposed owner |
226
+ | `owner()` | The node that owns what is created now: the running effect/computed, else the running root's scope, else `null`. Its `.owner` chain reaches every enclosing owner |
226
227
  | `onCleanup(fn)` | Registers a cleanup function for the current effect/computed |
227
228
 
228
229
  ### Low-Level Functions
@@ -301,6 +302,7 @@ All standard array methods (`push`, `pop`, `shift`, `unshift`, `splice`, `sort`,
301
302
 
302
303
  | Event | Payload | Description |
303
304
  |-------|---------|-------------|
305
+ | `batch` | `ArrayOp<T>[]` | Every mutation of a task, in order, each as `{ op, ...payload }` of its event; delivered once inside the reactivity pass that follows (`flush()` settles it), outside any owner. A `clear` drops the mutations before it |
304
306
  | `clear` | `undefined` | Array was cleared |
305
307
  | `concat` | `{ items: T[] }` | Items were concatenated |
306
308
  | `pop` | `{ item: T }` | Item was popped |
@@ -15,5 +15,6 @@ declare const STATE_IN_HEAP: number;
15
15
  declare const STATE_COMPUTED: number;
16
16
  declare const STATE_ERROR: number;
17
17
  declare const STATE_EFFECT: number;
18
+ declare const STATE_DISPOSED: number;
18
19
  declare const STATE_NOTIFY_MASK: number;
19
- export { COMPUTED, PACKAGE_NAME, REACTIVE_ARRAY, REACTIVE_OBJECT, 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 };
20
+ export { COMPUTED, PACKAGE_NAME, REACTIVE_ARRAY, REACTIVE_OBJECT, SIGNAL, STABILIZER_DEFERRED, STABILIZER_IDLE, STABILIZER_RESCHEDULE, STABILIZER_RUNNING, STABILIZER_SCHEDULED, STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_DISPOSED, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING };
@@ -15,5 +15,6 @@ const STATE_IN_HEAP = 1 << 3;
15
15
  const STATE_COMPUTED = 1 << 4;
16
16
  const STATE_ERROR = 1 << 5;
17
17
  const STATE_EFFECT = 1 << 6;
18
+ const STATE_DISPOSED = 1 << 7;
18
19
  const STATE_NOTIFY_MASK = (STATE_CHECK | STATE_DIRTY);
19
- export { COMPUTED, PACKAGE_NAME, REACTIVE_ARRAY, REACTIVE_OBJECT, 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 };
20
+ export { COMPUTED, PACKAGE_NAME, REACTIVE_ARRAY, REACTIVE_OBJECT, SIGNAL, STABILIZER_DEFERRED, STABILIZER_IDLE, STABILIZER_RESCHEDULE, STABILIZER_RUNNING, STABILIZER_SCHEDULED, STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_DISPOSED, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING };
package/build/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { isPromise } from '@esportsplus/utilities';
2
2
  export { COMPUTED, REACTIVE_ARRAY, REACTIVE_OBJECT, SIGNAL } from './constants.js';
3
3
  export { default as reactive, ReactiveArray, ReactiveObject } from './reactive/index.js';
4
+ export type { ArrayOp } from './reactive/array.js';
4
5
  export * from './system.js';
5
6
  export * from './types.js';
@@ -1,4 +1,37 @@
1
+ type ArrayOp<T> = {
2
+ items: T[];
3
+ op: 'concat';
4
+ } | {
5
+ deleteCount: number;
6
+ items: T[];
7
+ op: 'splice';
8
+ start: number;
9
+ } | {
10
+ items: T[];
11
+ op: 'push';
12
+ } | {
13
+ index: number;
14
+ item: T;
15
+ op: 'set';
16
+ } | {
17
+ items: T[];
18
+ op: 'unshift';
19
+ } | {
20
+ op: 'clear';
21
+ } | {
22
+ item: T;
23
+ op: 'pop';
24
+ } | {
25
+ op: 'reverse';
26
+ } | {
27
+ item: T;
28
+ op: 'shift';
29
+ } | {
30
+ op: 'sort';
31
+ order: number[];
32
+ };
1
33
  type Events<T> = {
34
+ batch: ArrayOp<T>[];
2
35
  clear: undefined;
3
36
  concat: {
4
37
  items: T[];
@@ -39,12 +72,18 @@ type Listeners<T> = {
39
72
  };
40
73
  declare class ReactiveArray<T> extends Array<T> {
41
74
  private _length;
75
+ private batched;
76
+ private delivering;
42
77
  listeners: Listeners<T>;
43
78
  constructor(items?: readonly T[]);
44
79
  static get [Symbol.species](): ArrayConstructor;
45
80
  get $length(): number;
46
81
  set $length(value: number);
47
82
  $set(i: number, value: T): void;
83
+ private batch;
84
+ private prune;
85
+ private record;
86
+ private unbatch;
48
87
  clear(): void;
49
88
  concat(...items: ConcatArray<T>[]): ReactiveArray<T>;
50
89
  concat(...items: (T | ConcatArray<T>)[]): ReactiveArray<T>;
@@ -52,7 +91,6 @@ declare class ReactiveArray<T> extends Array<T> {
52
91
  dispose(): void;
53
92
  on<K extends keyof Events<T>>(event: K, listener: Listener<Events<T>[K]>): VoidFunction;
54
93
  once<K extends keyof Events<T>>(event: K, listener: Listener<Events<T>[K]>): VoidFunction;
55
- private prune;
56
94
  pop(): T | undefined;
57
95
  push(...items: T[]): number;
58
96
  reverse(): this;
@@ -62,3 +100,4 @@ declare class ReactiveArray<T> extends Array<T> {
62
100
  unshift(...items: T[]): number;
63
101
  }
64
102
  export { ReactiveArray };
103
+ export type { ArrayOp };
@@ -1,6 +1,6 @@
1
1
  import { isArray } from '@esportsplus/utilities';
2
2
  import { PACKAGE_NAME, REACTIVE_ARRAY } from '../constants.js';
3
- import { read, signal, write } from '../system.js';
3
+ import { effect, read, root, signal, write } from '../system.js';
4
4
  import { isReactiveObject } from './object.js';
5
5
  function dispose(value) {
6
6
  if (isReactiveObject(value)) {
@@ -9,6 +9,8 @@ function dispose(value) {
9
9
  }
10
10
  class ReactiveArray extends Array {
11
11
  _length;
12
+ batched = null;
13
+ delivering = false;
12
14
  listeners = {};
13
15
  constructor(items) {
14
16
  super();
@@ -41,12 +43,71 @@ class ReactiveArray extends Array {
41
43
  write(this._length, i + 1);
42
44
  }
43
45
  this.dispatch('set', { index: i, item: value });
46
+ this.record({ index: i, item: value, op: 'set' });
44
47
  dispose(prev);
45
48
  }
49
+ batch() {
50
+ let tick = signal(0);
51
+ this.batched = {
52
+ ops: null,
53
+ stop: root(() => effect(() => {
54
+ read(tick);
55
+ let batched = this.batched, ops = batched?.ops;
56
+ if (!ops) {
57
+ return;
58
+ }
59
+ batched.ops = null;
60
+ this.delivering = true;
61
+ try {
62
+ root(() => this.dispatch('batch', ops));
63
+ }
64
+ finally {
65
+ this.delivering = false;
66
+ }
67
+ if (this.listeners.batch === undefined) {
68
+ queueMicrotask(() => {
69
+ if (this.listeners.batch === undefined) {
70
+ this.unbatch();
71
+ }
72
+ });
73
+ }
74
+ })),
75
+ tick
76
+ };
77
+ }
78
+ prune(event, listeners) {
79
+ while (listeners.length && listeners[listeners.length - 1] === null) {
80
+ listeners.pop();
81
+ }
82
+ if (listeners.length === 0 && this.listeners[event] === listeners) {
83
+ delete this.listeners[event];
84
+ }
85
+ }
86
+ record(op) {
87
+ let batched = this.batched;
88
+ if (batched === null) {
89
+ return;
90
+ }
91
+ if (op.op === 'clear' || batched.ops === null) {
92
+ batched.ops = [op];
93
+ }
94
+ else {
95
+ batched.ops.push(op);
96
+ }
97
+ write(batched.tick, batched.tick.value + 1);
98
+ }
99
+ unbatch() {
100
+ let batched = this.batched;
101
+ if (batched !== null) {
102
+ this.batched = null;
103
+ batched.stop();
104
+ }
105
+ }
46
106
  clear() {
47
107
  this.dispose();
48
108
  write(this._length, 0);
49
109
  this.dispatch('clear');
110
+ this.record({ op: 'clear' });
50
111
  }
51
112
  concat(...items) {
52
113
  let added = [];
@@ -66,6 +127,7 @@ class ReactiveArray extends Array {
66
127
  if (added.length) {
67
128
  write(this._length, this.length);
68
129
  this.dispatch('concat', { items: added });
130
+ this.record({ items: added, op: 'concat' });
69
131
  }
70
132
  return this;
71
133
  }
@@ -96,6 +158,9 @@ class ReactiveArray extends Array {
96
158
  if (dirty) {
97
159
  this.prune(event, listeners);
98
160
  }
161
+ if (event === 'batch' && !this.delivering && this.listeners.batch === undefined) {
162
+ this.unbatch();
163
+ }
99
164
  if (errors !== null) {
100
165
  let error = errors.length === 1
101
166
  ? errors[0]
@@ -113,6 +178,9 @@ class ReactiveArray extends Array {
113
178
  }
114
179
  on(event, listener) {
115
180
  let entry = listener, listeners = this.listeners[event];
181
+ if (event === 'batch' && this.batched === null) {
182
+ this.batch();
183
+ }
116
184
  if (listeners === undefined) {
117
185
  this.listeners[event] = [entry];
118
186
  }
@@ -143,20 +211,15 @@ class ReactiveArray extends Array {
143
211
  current[i] = null;
144
212
  this.prune(event, current);
145
213
  }
214
+ if (event === 'batch' && !this.delivering && this.listeners.batch === undefined) {
215
+ this.unbatch();
216
+ }
146
217
  };
147
218
  }
148
219
  once(event, listener) {
149
220
  listener.once = true;
150
221
  return this.on(event, listener);
151
222
  }
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
- }
159
- }
160
223
  pop() {
161
224
  if (this.length === 0) {
162
225
  return undefined;
@@ -165,6 +228,7 @@ class ReactiveArray extends Array {
165
228
  dispose(item);
166
229
  write(this._length, this.length);
167
230
  this.dispatch('pop', { item });
231
+ this.record({ item, op: 'pop' });
168
232
  return item;
169
233
  }
170
234
  push(...items) {
@@ -174,11 +238,13 @@ class ReactiveArray extends Array {
174
238
  let length = super.push(...items);
175
239
  write(this._length, length);
176
240
  this.dispatch('push', { items });
241
+ this.record({ items, op: 'push' });
177
242
  return length;
178
243
  }
179
244
  reverse() {
180
245
  super.reverse();
181
246
  this.dispatch('reverse');
247
+ this.record({ op: 'reverse' });
182
248
  return this;
183
249
  }
184
250
  shift() {
@@ -189,10 +255,11 @@ class ReactiveArray extends Array {
189
255
  dispose(item);
190
256
  write(this._length, this.length);
191
257
  this.dispatch('shift', { item });
258
+ this.record({ item, op: 'shift' });
192
259
  return item;
193
260
  }
194
261
  sort(fn) {
195
- if (this.listeners.sort === undefined) {
262
+ if (this.listeners.sort === undefined && this.batched === null) {
196
263
  super.sort(fn);
197
264
  }
198
265
  else {
@@ -216,6 +283,7 @@ class ReactiveArray extends Array {
216
283
  order[i] = bucket.indices[bucket.next++];
217
284
  }
218
285
  this.dispatch('sort', { order });
286
+ this.record({ op: 'sort', order });
219
287
  }
220
288
  return this;
221
289
  }
@@ -226,12 +294,9 @@ class ReactiveArray extends Array {
226
294
  for (let i = 0, n = removed.length; i < n; i++) {
227
295
  dispose(removed[i]);
228
296
  }
229
- let index = Math.trunc(start) || 0;
230
- this.dispatch('splice', {
231
- deleteCount: removed.length,
232
- items,
233
- start: index < 0 ? Math.max(length + index, 0) : Math.min(index, length)
234
- });
297
+ let index = Math.trunc(start) || 0, resolved = index < 0 ? Math.max(length + index, 0) : Math.min(index, length);
298
+ this.dispatch('splice', { deleteCount: removed.length, items, start: resolved });
299
+ this.record({ deleteCount: removed.length, items, op: 'splice', start: resolved });
235
300
  }
236
301
  return removed;
237
302
  }
@@ -242,6 +307,7 @@ class ReactiveArray extends Array {
242
307
  let length = super.unshift(...items);
243
308
  write(this._length, length);
244
309
  this.dispatch('unshift', { items });
310
+ this.record({ items, op: 'unshift' });
245
311
  return length;
246
312
  }
247
313
  }
package/build/system.d.ts CHANGED
@@ -13,12 +13,13 @@ declare const isSignal: (value: unknown) => value is Signal<unknown>;
13
13
  declare const onCleanup: (fn: VoidFunction) => typeof fn;
14
14
  declare const peek: <T>(node: Signal<T> | Computed<T>) => T;
15
15
  declare const read: <T>(node: Signal<T> | Computed<T>) => T;
16
- declare const root: <T>(fn: ((dispose: VoidFunction) => T) | (() => T)) => T;
16
+ declare const owner: () => Computed<unknown> | null;
17
+ declare const root: <T>(fn: ((dispose: VoidFunction) => T) | (() => T), parent?: Computed<unknown> | null) => T;
17
18
  declare function signal<T>(value: T, equals?: ((a: T, b: T) => boolean) | null): Signal<T>;
18
19
  declare namespace signal {
19
20
  var selector: <T>(node: Signal<T>, key: T) => boolean;
20
21
  }
21
22
  declare const untrack: <T>(fn: () => T) => T;
22
23
  declare const write: <T>(signal: Signal<T>, value: T) => void;
23
- export { batch, computed, dispose, effect, flush, hasOwner, isComputed, isSignal, onCleanup, peek, read, root, signal, untrack, write };
24
+ export { batch, computed, dispose, effect, flush, hasOwner, isComputed, isSignal, onCleanup, owner, peek, read, root, signal, untrack, write };
24
25
  export type { Computed, Signal };
package/build/system.js CHANGED
@@ -1,4 +1,4 @@
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';
1
+ import { PACKAGE_NAME, SIGNAL, STABILIZER_DEFERRED, STABILIZER_IDLE, STABILIZER_RESCHEDULE, STABILIZER_RUNNING, STABILIZER_SCHEDULED, STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_DISPOSED, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING } from './constants.js';
2
2
  import { isObject, isPromise } from '@esportsplus/utilities';
3
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() {
@@ -623,6 +623,7 @@ const dispose = (computed) => {
623
623
  try {
624
624
  for (;;) {
625
625
  deleteFromHeap(node);
626
+ node.state |= STATE_DISPOSED;
626
627
  let dep = node.deps;
627
628
  while (dep) {
628
629
  dep = unlink(dep);
@@ -751,12 +752,16 @@ const read = (node) => {
751
752
  }
752
753
  return node.value;
753
754
  };
754
- const root = (fn) => {
755
+ const owner = () => observer || scope;
756
+ const root = (fn, parent) => {
757
+ if (parent && parent.state & STATE_DISPOSED) {
758
+ throw new Error(`${PACKAGE_NAME}: root() was given a disposed owner`);
759
+ }
755
760
  let o = observer, s = scope, tracking = fn.length, value;
756
761
  observer = null;
757
762
  try {
758
763
  if (tracking) {
759
- let self = makeNode(noop), owner = o || s;
764
+ let self = makeNode(noop), owner = parent === undefined ? o || s : parent;
760
765
  if (owner !== null) {
761
766
  own(owner, self);
762
767
  }
@@ -764,7 +769,7 @@ const root = (fn) => {
764
769
  value = fn(() => dispose(self));
765
770
  }
766
771
  else {
767
- scope = null;
772
+ scope = parent ?? null;
768
773
  value = fn();
769
774
  }
770
775
  }
@@ -854,4 +859,4 @@ const write = (signal, value) => {
854
859
  }
855
860
  schedule();
856
861
  };
857
- export { batch, computed, dispose, effect, flush, hasOwner, isComputed, isSignal, onCleanup, peek, read, root, signal, untrack, write };
862
+ export { batch, computed, dispose, effect, flush, hasOwner, isComputed, isSignal, onCleanup, owner, peek, read, root, signal, untrack, write };
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "@esportsplus/utilities": "^0.28.6"
5
5
  },
6
6
  "devDependencies": {
7
- "@esportsplus/typescript": "^0.37.0",
7
+ "@esportsplus/typescript": "^0.38.0",
8
8
  "@types/node": "^26.4.1",
9
9
  "vite": "^8.2.2",
10
10
  "vitest": "^5.0.0"
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "type": "module",
38
38
  "types": "build/index.d.ts",
39
- "version": "0.39.1",
39
+ "version": "0.40.0",
40
40
  "scripts": {
41
41
  "agent:bench": "vitest bench --run",
42
42
  "agent:test": "tsc --noEmit && vitest run",
@@ -2,7 +2,7 @@ allowBuilds:
2
2
  esbuild: true
3
3
  minimumReleaseAgeExclude:
4
4
  - '@esportsplus/utilities@0.28.1 || 0.28.3 || 0.28.4 || 0.28.6'
5
- - '@esportsplus/typescript@0.31.0 || 0.32.0 || 0.33.0 || 0.33.2 || 0.33.4 || 0.34.1 || 0.34.3 || 0.34.4 || 0.36.0 || 0.36.1 || 0.37.0'
5
+ - '@esportsplus/typescript@0.31.0 || 0.32.0 || 0.33.0 || 0.33.2 || 0.33.4 || 0.34.1 || 0.34.3 || 0.34.4 || 0.36.0 || 0.36.1 || 0.37.0 || 0.38.0'
6
6
  - tsc-alias@1.9.4
7
7
  - '@vitest/mocker@5.0.0'
8
8
  - '@vitest/spy@5.0.0'
package/src/constants.ts CHANGED
@@ -32,6 +32,9 @@ const STATE_ERROR = 1 << 5;
32
32
 
33
33
  const STATE_EFFECT = 1 << 6;
34
34
 
35
+ // Set by dispose(): a node that can never run or own anything again
36
+ const STATE_DISPOSED = 1 << 7;
37
+
35
38
  const STATE_NOTIFY_MASK = (STATE_CHECK | STATE_DIRTY);
36
39
 
37
40
 
@@ -41,5 +44,5 @@ export {
41
44
  REACTIVE_ARRAY, REACTIVE_OBJECT,
42
45
  SIGNAL,
43
46
  STABILIZER_DEFERRED, STABILIZER_IDLE, STABILIZER_RESCHEDULE, STABILIZER_RUNNING, STABILIZER_SCHEDULED,
44
- STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING
47
+ STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_DISPOSED, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING
45
48
  };
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { isPromise } from '@esportsplus/utilities';
2
2
  export { COMPUTED, REACTIVE_ARRAY, REACTIVE_OBJECT, SIGNAL } from './constants';
3
3
  export { default as reactive, ReactiveArray, ReactiveObject } from './reactive';
4
+ export type { ArrayOp } from './reactive/array';
4
5
  export * from './system';
5
6
  export * from './types';
@@ -1,11 +1,27 @@
1
1
  import { isArray } from '@esportsplus/utilities';
2
2
  import { PACKAGE_NAME, REACTIVE_ARRAY } from '~/constants';
3
- import { read, signal, write } from '~/system';
3
+ import { effect, read, root, signal, write } from '~/system';
4
4
  import type { Signal } from '~/types';
5
5
  import { isReactiveObject } from './object';
6
6
 
7
7
 
8
+ // One mutation as the batch event reports it: the matching event's payload, named by 'op'
9
+ type ArrayOp<T> =
10
+ | { items: T[]; op: 'concat' }
11
+ | { deleteCount: number; items: T[]; op: 'splice'; start: number }
12
+ | { items: T[]; op: 'push' }
13
+ | { index: number; item: T; op: 'set' }
14
+ | { items: T[]; op: 'unshift' }
15
+ | { op: 'clear' }
16
+ | { item: T; op: 'pop' }
17
+ | { op: 'reverse' }
18
+ | { item: T; op: 'shift' }
19
+ | { op: 'sort'; order: number[] };
20
+
8
21
  type Events<T> = {
22
+ // Every mutation of a task, in order, delivered once inside the reactivity pass that follows it (flush() settles
23
+ // it); a clear drops the mutations before it
24
+ batch: ArrayOp<T>[];
9
25
  clear: undefined,
10
26
  concat: {
11
27
  items: T[];
@@ -60,6 +76,11 @@ class ReactiveArray<T> extends Array<T> {
60
76
 
61
77
  private _length: Signal<number>;
62
78
 
79
+ // The batch listeners' delivery: the mutations not yet delivered, and the effect that delivers them
80
+ private batched: { ops: ArrayOp<T>[] | null; stop: VoidFunction; tick: Signal<number> } | null = null;
81
+
82
+ private delivering = false;
83
+
63
84
  listeners: Listeners<T> = {};
64
85
 
65
86
 
@@ -110,14 +131,95 @@ class ReactiveArray<T> extends Array<T> {
110
131
  }
111
132
 
112
133
  this.dispatch('set', { index: i, item: value });
134
+ this.record({ index: i, item: value, op: 'set' });
113
135
  dispose(prev);
114
136
  }
115
137
 
116
138
 
139
+ // Starts delivering batches: an effect of its own, outside any owner, since the array outlives whoever listens
140
+ private batch() {
141
+ let tick = signal(0);
142
+
143
+ this.batched = {
144
+ ops: null,
145
+ stop: root(() => effect(() => {
146
+ read(tick);
147
+
148
+ let batched = this.batched,
149
+ ops = batched?.ops;
150
+
151
+ if (!ops) {
152
+ return;
153
+ }
154
+
155
+ batched!.ops = null;
156
+ this.delivering = true;
157
+
158
+ try {
159
+ // What listeners build belongs to them, not to this effect
160
+ root(() => this.dispatch('batch', ops));
161
+ }
162
+ finally {
163
+ this.delivering = false;
164
+ }
165
+
166
+ // Disposing this effect inside its own run corrupts its dependency links, so the stop waits for the
167
+ // run to end; a listener that joins meanwhile keeps it
168
+ if (this.listeners.batch === undefined) {
169
+ queueMicrotask(() => {
170
+ if (this.listeners.batch === undefined) {
171
+ this.unbatch();
172
+ }
173
+ });
174
+ }
175
+ })),
176
+ tick
177
+ };
178
+ }
179
+
180
+ // Trims trailing empty slots; an event left with no listeners is deleted, so e.g. sort() skips computing its order
181
+ private prune<K extends keyof Events<T>>(event: K, listeners: (AnyListener<T> | null)[]) {
182
+ while (listeners.length && listeners[listeners.length - 1] === null) {
183
+ listeners.pop();
184
+ }
185
+
186
+ if (listeners.length === 0 && this.listeners[event] === listeners) {
187
+ delete this.listeners[event];
188
+ }
189
+ }
190
+
191
+ private record(op: ArrayOp<T>) {
192
+ let batched = this.batched;
193
+
194
+ if (batched === null) {
195
+ return;
196
+ }
197
+
198
+ if (op.op === 'clear' || batched.ops === null) {
199
+ batched.ops = [op];
200
+ }
201
+ else {
202
+ batched.ops.push(op);
203
+ }
204
+
205
+ write(batched.tick, batched.tick.value + 1);
206
+ }
207
+
208
+ private unbatch() {
209
+ let batched = this.batched;
210
+
211
+ if (batched !== null) {
212
+ this.batched = null;
213
+ batched.stop();
214
+ }
215
+ }
216
+
217
+
117
218
  clear() {
118
219
  this.dispose();
119
220
  write(this._length, 0);
120
221
  this.dispatch('clear');
222
+ this.record({ op: 'clear' });
121
223
  }
122
224
 
123
225
  concat(...items: ConcatArray<T>[]): ReactiveArray<T>;
@@ -143,6 +245,7 @@ class ReactiveArray<T> extends Array<T> {
143
245
  if (added.length) {
144
246
  write(this._length, this.length);
145
247
  this.dispatch('concat', { items: added });
248
+ this.record({ items: added, op: 'concat' });
146
249
  }
147
250
 
148
251
  return this;
@@ -185,6 +288,10 @@ class ReactiveArray<T> extends Array<T> {
185
288
  this.prune(event, listeners);
186
289
  }
187
290
 
291
+ if (event === 'batch' && !this.delivering && this.listeners.batch === undefined) {
292
+ this.unbatch();
293
+ }
294
+
188
295
  if (errors !== null) {
189
296
  let error = errors.length === 1
190
297
  ? errors[0]
@@ -210,6 +317,10 @@ class ReactiveArray<T> extends Array<T> {
210
317
  let entry = listener as AnyListener<T>,
211
318
  listeners = this.listeners[event];
212
319
 
320
+ if (event === 'batch' && this.batched === null) {
321
+ this.batch();
322
+ }
323
+
213
324
  if (listeners === undefined) {
214
325
  this.listeners[event] = [entry];
215
326
  }
@@ -248,6 +359,10 @@ class ReactiveArray<T> extends Array<T> {
248
359
  current[i] = null;
249
360
  this.prune(event, current);
250
361
  }
362
+
363
+ if (event === 'batch' && !this.delivering && this.listeners.batch === undefined) {
364
+ this.unbatch();
365
+ }
251
366
  };
252
367
  }
253
368
 
@@ -257,17 +372,6 @@ class ReactiveArray<T> extends Array<T> {
257
372
  return this.on(event, listener);
258
373
  }
259
374
 
260
- // Trims trailing empty slots; an event left with no listeners is deleted, so e.g. sort() skips computing its order
261
- private prune<K extends keyof Events<T>>(event: K, listeners: (AnyListener<T> | null)[]) {
262
- while (listeners.length && listeners[listeners.length - 1] === null) {
263
- listeners.pop();
264
- }
265
-
266
- if (listeners.length === 0 && this.listeners[event] === listeners) {
267
- delete this.listeners[event];
268
- }
269
- }
270
-
271
375
  pop() {
272
376
  if (this.length === 0) {
273
377
  return undefined;
@@ -278,6 +382,7 @@ class ReactiveArray<T> extends Array<T> {
278
382
  dispose(item);
279
383
  write(this._length, this.length);
280
384
  this.dispatch('pop', { item });
385
+ this.record({ item, op: 'pop' });
281
386
 
282
387
  return item;
283
388
  }
@@ -292,6 +397,7 @@ class ReactiveArray<T> extends Array<T> {
292
397
  write(this._length, length);
293
398
 
294
399
  this.dispatch('push', { items });
400
+ this.record({ items, op: 'push' });
295
401
 
296
402
  return length;
297
403
  }
@@ -299,6 +405,7 @@ class ReactiveArray<T> extends Array<T> {
299
405
  reverse() {
300
406
  super.reverse();
301
407
  this.dispatch('reverse');
408
+ this.record({ op: 'reverse' });
302
409
 
303
410
  return this;
304
411
  }
@@ -313,12 +420,13 @@ class ReactiveArray<T> extends Array<T> {
313
420
  dispose(item);
314
421
  write(this._length, this.length);
315
422
  this.dispatch('shift', { item });
423
+ this.record({ item, op: 'shift' });
316
424
 
317
425
  return item;
318
426
  }
319
427
 
320
428
  sort(fn?: (a: T, b: T) => number) {
321
- if (this.listeners.sort === undefined) {
429
+ if (this.listeners.sort === undefined && this.batched === null) {
322
430
  super.sort(fn);
323
431
  }
324
432
  else {
@@ -353,6 +461,7 @@ class ReactiveArray<T> extends Array<T> {
353
461
  }
354
462
 
355
463
  this.dispatch('sort', { order });
464
+ this.record({ op: 'sort', order });
356
465
  }
357
466
 
358
467
  return this;
@@ -371,13 +480,11 @@ class ReactiveArray<T> extends Array<T> {
371
480
 
372
481
  // Listeners receive the position and count Array.prototype.splice actually applied
373
482
  // (negative, fractional and out-of-range arguments resolved), never the raw arguments
374
- let index = Math.trunc(start) || 0;
483
+ let index = Math.trunc(start) || 0,
484
+ resolved = index < 0 ? Math.max(length + index, 0) : Math.min(index, length);
375
485
 
376
- this.dispatch('splice', {
377
- deleteCount: removed.length,
378
- items,
379
- start: index < 0 ? Math.max(length + index, 0) : Math.min(index, length)
380
- });
486
+ this.dispatch('splice', { deleteCount: removed.length, items, start: resolved });
487
+ this.record({ deleteCount: removed.length, items, op: 'splice', start: resolved });
381
488
  }
382
489
 
383
490
  return removed;
@@ -392,6 +499,7 @@ class ReactiveArray<T> extends Array<T> {
392
499
 
393
500
  write(this._length, length);
394
501
  this.dispatch('unshift', { items });
502
+ this.record({ items, op: 'unshift' });
395
503
 
396
504
  return length;
397
505
  }
@@ -401,3 +509,4 @@ Object.defineProperty(ReactiveArray.prototype, REACTIVE_ARRAY, { value: true });
401
509
 
402
510
 
403
511
  export { ReactiveArray };
512
+ export type { ArrayOp };
package/src/system.ts CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  PACKAGE_NAME,
3
3
  SIGNAL,
4
4
  STABILIZER_DEFERRED, STABILIZER_IDLE, STABILIZER_RESCHEDULE, STABILIZER_RUNNING, STABILIZER_SCHEDULED,
5
- STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING
5
+ STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_DISPOSED, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING
6
6
  } from './constants';
7
7
  import { Computed, ComputedResult, Link, SelectorSignal, Settled, Signal } from './types';
8
8
  import { isObject, isPromise } from '@esportsplus/utilities';
@@ -941,6 +941,7 @@ const dispose = <T>(computed: Computed<T>): void => {
941
941
  try {
942
942
  for (;;) {
943
943
  deleteFromHeap(node);
944
+ node.state |= STATE_DISPOSED;
944
945
 
945
946
  let dep = node.deps;
946
947
 
@@ -1116,9 +1117,21 @@ const read = <T>(node: Signal<T> | Computed<T>): T => {
1116
1117
  return node.value;
1117
1118
  };
1118
1119
 
1120
+ // The node that owns what is created now: the running computed or effect, else the running root's scope. Walking its
1121
+ // .owner chain finds every enclosing owner.
1122
+ const owner = (): Computed<unknown> | null => observer || scope;
1123
+
1119
1124
  // Owned before fn runs (nothing can register on the owner meanwhile, so ownerSlot is unchanged): a
1120
1125
  // root disposed inside its own fn then unlinks instead of being parked on the owner as a dead child.
1121
- const root = <T>(fn: ((dispose: VoidFunction) => T) | (() => T)) => {
1126
+ //
1127
+ // 'parent' replaces whatever is running as the owner, for ownership only: reads in fn stay untracked. With it,
1128
+ // root(() => ...) runs fn owned by 'parent' (re-entering it after an await) and root(dispose => ...) creates a
1129
+ // child root of 'parent'. A disposed node cannot own anything: nothing would dispose what it took.
1130
+ const root = <T>(fn: ((dispose: VoidFunction) => T) | (() => T), parent?: Computed<unknown> | null) => {
1131
+ if (parent && parent.state & STATE_DISPOSED) {
1132
+ throw new Error(`${PACKAGE_NAME}: root() was given a disposed owner`);
1133
+ }
1134
+
1122
1135
  let o = observer,
1123
1136
  s = scope,
1124
1137
  tracking = fn.length,
@@ -1129,7 +1142,7 @@ const root = <T>(fn: ((dispose: VoidFunction) => T) | (() => T)) => {
1129
1142
  try {
1130
1143
  if (tracking) {
1131
1144
  let self = makeNode(noop),
1132
- owner = o || s;
1145
+ owner = parent === undefined ? o || s : parent;
1133
1146
 
1134
1147
  if (owner !== null) {
1135
1148
  own(owner, self);
@@ -1139,7 +1152,7 @@ const root = <T>(fn: ((dispose: VoidFunction) => T) | (() => T)) => {
1139
1152
  value = (fn as (dispose: VoidFunction) => T)(() => dispose(self));
1140
1153
  }
1141
1154
  else {
1142
- scope = null;
1155
+ scope = parent ?? null;
1143
1156
  value = (fn as () => T)();
1144
1157
  }
1145
1158
  }
@@ -1269,7 +1282,7 @@ export {
1269
1282
  flush,
1270
1283
  hasOwner,
1271
1284
  isComputed, isSignal,
1272
- onCleanup,
1285
+ onCleanup, owner,
1273
1286
  peek,
1274
1287
  read, root,
1275
1288
  signal,
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { computed, dispose, effect, flush, hasOwner, onCleanup, read, root, signal, untrack, write } from '~/system';
2
+ import { computed, dispose, effect, flush, hasOwner, onCleanup, owner as current, read, root, signal, untrack, write } from '~/system';
3
3
  import type { Computed } from '~/system';
4
4
  import { captureUncaught } from './lib/uncaught';
5
5
 
@@ -584,3 +584,102 @@ describe('hasOwner', () => {
584
584
  });
585
585
  });
586
586
 
587
+ describe('owner', () => {
588
+ it('is the running computation, else the running root scope, else null', () => {
589
+ let running: Computed<unknown> | null = null,
590
+ scope: Computed<unknown> | null = null,
591
+ seen: unknown[] = [];
592
+
593
+ seen.push(current());
594
+
595
+ let stop = root((dispose) => {
596
+ scope = current();
597
+
598
+ effect(() => {
599
+ running = current();
600
+ seen.push(untrack(() => current()) === running);
601
+ });
602
+
603
+ return dispose;
604
+ });
605
+
606
+ seen.push(current());
607
+
608
+ expect(scope).not.toBeNull();
609
+ expect(running).not.toBe(scope);
610
+ // An effect is owned once its first run returns
611
+ expect(running!.owner).toBe(scope);
612
+
613
+ stop();
614
+
615
+ expect(seen).toEqual([null, true, null]);
616
+ });
617
+ });
618
+
619
+ describe('root with an owner', () => {
620
+ it('runs fn owned by the given node, its reads untracked', async () => {
621
+ let cleaned = 0,
622
+ s = signal(0),
623
+ node: Computed<unknown> | null = null,
624
+ runs = 0,
625
+ stop = root((dispose) => {
626
+ node = current();
627
+
628
+ return dispose;
629
+ });
630
+
631
+ root(() => {
632
+ runs++;
633
+ read(s);
634
+ onCleanup(() => cleaned++);
635
+ effect(() => { read(s); });
636
+ }, node);
637
+
638
+ expect(hasOwner()).toBe(false);
639
+ expect(registrations(node!)).toBe(2);
640
+
641
+ write(s, 1);
642
+ await Promise.resolve();
643
+
644
+ expect(runs).toBe(1);
645
+
646
+ stop();
647
+
648
+ expect(cleaned).toBe(1);
649
+ expect(registrations(node!)).toBe(0);
650
+ });
651
+
652
+ it('creates a child root of the given node instead of whatever is running', () => {
653
+ let a: Computed<unknown> | null = null,
654
+ b: Computed<unknown> | null = null,
655
+ child: Computed<unknown> | null = null,
656
+ stopA = root((dispose) => { a = current(); return dispose; }),
657
+ stopB = root((dispose) => { b = current(); return dispose; });
658
+
659
+ root(() => {
660
+ root((dispose) => {
661
+ child = current();
662
+
663
+ return dispose;
664
+ }, a);
665
+ }, b);
666
+
667
+ expect(child!.owner).toBe(a);
668
+ expect(registrations(b!)).toBe(0);
669
+
670
+ stopA();
671
+
672
+ expect(child!.owner).toBe(null);
673
+
674
+ stopB();
675
+ });
676
+
677
+ it('throws on a disposed owner', () => {
678
+ let node: Computed<unknown> | null = null,
679
+ stop = root((dispose) => { node = current(); return dispose; });
680
+
681
+ stop();
682
+
683
+ expect(() => root(() => {}, node)).toThrow('@esportsplus/reactivity: root() was given a disposed owner');
684
+ });
685
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { effect, read, signal, write } from '~/system';
2
+ import { effect, flush, owner, read, root, signal, write } from '~/system';
3
3
  import { ReactiveArray } from '~/reactive/array';
4
4
  import { ReactiveObject } from '~/reactive/object';
5
5
  import reactive from '~/reactive/index';
@@ -1226,3 +1226,183 @@ describe('ReactiveArray', () => {
1226
1226
  });
1227
1227
  });
1228
1228
  });
1229
+
1230
+ describe('ReactiveArray batch', () => {
1231
+ // Disposed links go back to a shared pool; a node disposed mid-run then walks pooled links
1232
+ let pool = () => {
1233
+ let a = signal(0),
1234
+ b = signal(0),
1235
+ c = signal(0);
1236
+
1237
+ root(() => effect(() => read(a) + read(b) + read(c)))();
1238
+ };
1239
+
1240
+ it('delivers every mutation of a task once, in order, in the pass that follows', async () => {
1241
+ let arr = new ReactiveArray([1, 2, 3]),
1242
+ batches: unknown[][] = [];
1243
+
1244
+ arr.on('batch', (ops) => batches.push(ops));
1245
+ arr.push(4);
1246
+ arr.$set(0, 9);
1247
+ arr.splice(1, 1, 7, 8);
1248
+ arr.pop();
1249
+
1250
+ expect(batches).toEqual([]);
1251
+
1252
+ await Promise.resolve();
1253
+
1254
+ expect(batches).toEqual([[
1255
+ { items: [4], op: 'push' },
1256
+ { index: 0, item: 9, op: 'set' },
1257
+ { deleteCount: 1, items: [7, 8], op: 'splice', start: 1 },
1258
+ { item: 4, op: 'pop' }
1259
+ ]]);
1260
+ });
1261
+
1262
+ it('settles through flush()', () => {
1263
+ let arr = new ReactiveArray<number>(),
1264
+ batches: unknown[][] = [];
1265
+
1266
+ arr.on('batch', (ops) => batches.push(ops));
1267
+ arr.push(1, 2);
1268
+ flush();
1269
+
1270
+ expect(batches).toEqual([[{ items: [1, 2], op: 'push' }]]);
1271
+ });
1272
+
1273
+ it('drops the mutations before a clear', () => {
1274
+ let arr = new ReactiveArray([1]),
1275
+ batches: unknown[][] = [];
1276
+
1277
+ arr.on('batch', (ops) => batches.push(ops));
1278
+ arr.push(2);
1279
+ arr.clear();
1280
+ arr.unshift(5);
1281
+ flush();
1282
+
1283
+ expect(batches).toEqual([[{ op: 'clear' }, { items: [5], op: 'unshift' }]]);
1284
+ });
1285
+
1286
+ it('reports the order of a sort, and reverse, shift and concat', () => {
1287
+ let arr = new ReactiveArray([3, 1, 2]),
1288
+ batches: unknown[][] = [];
1289
+
1290
+ arr.on('batch', (ops) => batches.push(ops));
1291
+ arr.sort((a, b) => a - b);
1292
+ arr.reverse();
1293
+ arr.shift();
1294
+ arr.concat([9]);
1295
+ flush();
1296
+
1297
+ expect(batches).toEqual([[
1298
+ { op: 'sort', order: [1, 2, 0] },
1299
+ { op: 'reverse' },
1300
+ { item: 3, op: 'shift' },
1301
+ { items: [9], op: 'concat' }
1302
+ ]]);
1303
+ });
1304
+
1305
+ it('runs listeners outside any owner, and stops delivering once the last one leaves', () => {
1306
+ let arr = new ReactiveArray<number>(),
1307
+ seen: unknown[] = [],
1308
+ stop = arr.on('batch', () => seen.push(owner()));
1309
+
1310
+ arr.push(1);
1311
+ flush();
1312
+ stop();
1313
+ arr.push(2);
1314
+ flush();
1315
+
1316
+ expect(seen).toEqual([null]);
1317
+ });
1318
+
1319
+ it('delivers mutations made inside an effect within the same pass', () => {
1320
+ let arr = new ReactiveArray<number>(),
1321
+ batches: unknown[][] = [],
1322
+ s = signal(0);
1323
+
1324
+ arr.on('batch', (ops) => batches.push(ops));
1325
+
1326
+ let stop = root(() => effect(() => {
1327
+ if (read(s)) {
1328
+ arr.push(read(s));
1329
+ }
1330
+ }));
1331
+
1332
+ write(s, 5);
1333
+ flush();
1334
+ stop();
1335
+
1336
+ expect(batches).toEqual([[{ items: [5], op: 'push' }]]);
1337
+ });
1338
+
1339
+ it('keeps delivering to later listeners after the last one throws and is dropped', async () => {
1340
+ pool();
1341
+
1342
+ let arr = new ReactiveArray<number>(),
1343
+ batches: unknown[][] = [],
1344
+ lengths: number[] = [],
1345
+ stop = root(() => effect(() => { lengths.push(arr.$length); }));
1346
+
1347
+ let captured = await captureUncaught(() => {
1348
+ arr.on('batch', () => { throw new Error('reactivity: batch'); });
1349
+ arr.push(1);
1350
+ flush();
1351
+ });
1352
+
1353
+ arr.on('batch', (ops) => batches.push(ops));
1354
+ arr.push(2);
1355
+ flush();
1356
+ stop();
1357
+
1358
+ expect(captured.map((e) => (e as Error).message)).toEqual(['reactivity: batch']);
1359
+ expect(batches).toEqual([[{ items: [2], op: 'push' }]]);
1360
+ expect(lengths).toEqual([0, 1, 2]);
1361
+ });
1362
+
1363
+ it('keeps delivering to later listeners after the last one unsubscribes itself during dispatch', async () => {
1364
+ pool();
1365
+
1366
+ let arr = new ReactiveArray<number>(),
1367
+ batches: unknown[][] = [],
1368
+ first: unknown[][] = [],
1369
+ lengths: number[] = [],
1370
+ stop = root(() => effect(() => { lengths.push(arr.$length); })),
1371
+ unsubscribe = arr.on('batch', (ops) => {
1372
+ first.push(ops);
1373
+ unsubscribe();
1374
+ });
1375
+
1376
+ arr.push(1);
1377
+ flush();
1378
+ await Promise.resolve();
1379
+
1380
+ arr.push(2);
1381
+ flush();
1382
+
1383
+ arr.on('batch', (ops) => batches.push(ops));
1384
+ arr.push(3);
1385
+ flush();
1386
+ stop();
1387
+
1388
+ expect(first).toEqual([[{ items: [1], op: 'push' }]]);
1389
+ expect(batches).toEqual([[{ items: [3], op: 'push' }]]);
1390
+ expect(lengths).toEqual([0, 1, 2, 3]);
1391
+ });
1392
+
1393
+ it('keeps the delivering effect when a listener joins before the stop runs', () => {
1394
+ let arr = new ReactiveArray<number>(),
1395
+ batches: unknown[][] = [],
1396
+ unsubscribe = arr.on('batch', () => {
1397
+ unsubscribe();
1398
+ arr.on('batch', (ops) => batches.push(ops));
1399
+ });
1400
+
1401
+ arr.push(1);
1402
+ flush();
1403
+ arr.push(2);
1404
+ flush();
1405
+
1406
+ expect(batches).toEqual([[{ items: [2], op: 'push' }]]);
1407
+ });
1408
+ });