@esportsplus/reactivity 0.39.2 → 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 +3 -1
- package/build/constants.d.ts +2 -1
- package/build/constants.js +2 -1
- package/build/index.d.ts +1 -0
- package/build/reactive/array.d.ts +40 -1
- package/build/reactive/array.js +82 -16
- package/build/system.d.ts +3 -2
- package/build/system.js +10 -5
- package/package.json +1 -1
- package/src/constants.ts +4 -1
- package/src/index.ts +1 -0
- package/src/reactive/array.ts +128 -19
- package/src/system.ts +18 -5
- package/test/ownership.test.ts +100 -1
- package/test/reactive/array.test.ts +181 -1
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 |
|
package/build/constants.d.ts
CHANGED
|
@@ -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 };
|
package/build/constants.js
CHANGED
|
@@ -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 };
|
package/build/reactive/array.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
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';
|
package/src/reactive/array.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/test/ownership.test.ts
CHANGED
|
@@ -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
|
+
});
|