@esportsplus/reactivity 0.39.0 → 0.39.2
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 +1 -1
- package/build/system.js +6 -0
- package/package.json +2 -2
- package/pnpm-workspace.yaml +1 -1
- package/src/system.ts +12 -1
- package/test/ownership.test.ts +19 -2
- package/test/untrack.test.ts +225 -1
package/README.md
CHANGED
|
@@ -236,7 +236,7 @@ These operate directly on signal/computed nodes — the transformer emits them,
|
|
|
236
236
|
| `read(node)` | Reads a signal or computed value, subscribing the current scope |
|
|
237
237
|
| `write(signal, value)` | Sets a signal value |
|
|
238
238
|
| `peek(node)` | Reads a signal/computed's current value **without** subscribing (returns an up-to-date value even for a dirty computed) |
|
|
239
|
-
| `untrack(fn)` | Runs `fn` without tracking dependencies; returns `fn`'s result |
|
|
239
|
+
| `untrack(fn)` | Runs `fn` without tracking dependencies; returns `fn`'s result. Ownership is unchanged: effects, computeds, child roots and `onCleanup` created inside are owned by the running effect/computed (or the enclosing `root((dispose) => …)`) and torn down on its next re-run or dispose. Use a zero-arg `root(() => …)` for deliberately detached work |
|
|
240
240
|
| `batch(fn)` | Groups writes so dependent effects defer until `fn` returns; pair with `flush()` for a synchronous transaction |
|
|
241
241
|
| `flush()` | Synchronously settles all pending computed/effect updates |
|
|
242
242
|
| `dispose(computed)` | Disposes a computed and its dependencies |
|
package/build/system.js
CHANGED
|
@@ -813,12 +813,18 @@ signal.selector = (node, key) => {
|
|
|
813
813
|
};
|
|
814
814
|
const untrack = (fn) => {
|
|
815
815
|
let o = observer;
|
|
816
|
+
if (o === null) {
|
|
817
|
+
return fn();
|
|
818
|
+
}
|
|
819
|
+
let s = scope;
|
|
816
820
|
observer = null;
|
|
821
|
+
scope = o;
|
|
817
822
|
try {
|
|
818
823
|
return fn();
|
|
819
824
|
}
|
|
820
825
|
finally {
|
|
821
826
|
observer = o;
|
|
827
|
+
scope = s;
|
|
822
828
|
}
|
|
823
829
|
};
|
|
824
830
|
const write = (signal, value) => {
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"@esportsplus/utilities": "^0.28.6"
|
|
5
5
|
},
|
|
6
6
|
"devDependencies": {
|
|
7
|
-
"@esportsplus/typescript": "^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.
|
|
39
|
+
"version": "0.39.2",
|
|
40
40
|
"scripts": {
|
|
41
41
|
"agent:bench": "vitest bench --run",
|
|
42
42
|
"agent:test": "tsc --noEmit && vitest run",
|
package/pnpm-workspace.yaml
CHANGED
|
@@ -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/system.ts
CHANGED
|
@@ -1032,7 +1032,8 @@ const flush = (): void => {
|
|
|
1032
1032
|
}
|
|
1033
1033
|
};
|
|
1034
1034
|
|
|
1035
|
-
// Whether an onCleanup() made now would land on an owner (a running computed/effect
|
|
1035
|
+
// Whether an onCleanup() made now would land on an owner (a running computed/effect, also inside
|
|
1036
|
+
// untrack, or a root's scope)
|
|
1036
1037
|
const hasOwner = (): boolean => observer !== null || scope !== null;
|
|
1037
1038
|
|
|
1038
1039
|
const isComputed = (value: unknown): value is Computed<unknown> => {
|
|
@@ -1195,16 +1196,26 @@ signal.selector = <T>(node: Signal<T>, key: T): boolean => {
|
|
|
1195
1196
|
return read(entry);
|
|
1196
1197
|
};
|
|
1197
1198
|
|
|
1199
|
+
// Pauses tracking without changing ownership: work created inside fn is owned by the running
|
|
1200
|
+
// computation (as scope), so its next re-run or dispose tears it down. Outside one this is a plain call.
|
|
1198
1201
|
const untrack = <T>(fn: () => T): T => {
|
|
1199
1202
|
let o = observer;
|
|
1200
1203
|
|
|
1204
|
+
if (o === null) {
|
|
1205
|
+
return fn();
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
let s = scope;
|
|
1209
|
+
|
|
1201
1210
|
observer = null;
|
|
1211
|
+
scope = o;
|
|
1202
1212
|
|
|
1203
1213
|
try {
|
|
1204
1214
|
return fn();
|
|
1205
1215
|
}
|
|
1206
1216
|
finally {
|
|
1207
1217
|
observer = o;
|
|
1218
|
+
scope = s;
|
|
1208
1219
|
}
|
|
1209
1220
|
};
|
|
1210
1221
|
|
package/test/ownership.test.ts
CHANGED
|
@@ -551,19 +551,36 @@ describe('ownership throwing cleanup', () => {
|
|
|
551
551
|
|
|
552
552
|
|
|
553
553
|
describe('hasOwner', () => {
|
|
554
|
-
it('is true inside a tracking root or a run, false outside and in a detached root', () => {
|
|
554
|
+
it('is true inside a tracking root or a run (also untracked), false outside and in a detached root', () => {
|
|
555
555
|
let seen: boolean[] = [],
|
|
556
556
|
stop = effect(() => {
|
|
557
557
|
seen.push(hasOwner());
|
|
558
558
|
seen.push(untrack(() => hasOwner()));
|
|
559
|
+
seen.push(untrack(() => root(() => hasOwner())));
|
|
559
560
|
});
|
|
560
561
|
|
|
561
562
|
seen.push(hasOwner());
|
|
563
|
+
seen.push(untrack(() => hasOwner()));
|
|
562
564
|
root((d) => { seen.push(hasOwner()); d(); });
|
|
563
565
|
root(() => { seen.push(hasOwner()); });
|
|
564
566
|
stop();
|
|
565
567
|
|
|
566
|
-
expect(seen).toEqual([true, false, false, true, false]);
|
|
568
|
+
expect(seen).toEqual([true, true, false, false, false, true, false]);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('is true inside untrack during a re-run', async () => {
|
|
572
|
+
let s = signal(0),
|
|
573
|
+
seen: boolean[] = [],
|
|
574
|
+
stop = effect(() => {
|
|
575
|
+
read(s);
|
|
576
|
+
seen.push(untrack(() => hasOwner()));
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
write(s, 1);
|
|
580
|
+
await Promise.resolve();
|
|
581
|
+
stop();
|
|
582
|
+
|
|
583
|
+
expect(seen).toEqual([true, true]);
|
|
567
584
|
});
|
|
568
585
|
});
|
|
569
586
|
|
package/test/untrack.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { computed, effect, peek, read, signal, untrack, write } from '~/system';
|
|
2
|
+
import { computed, effect, hasOwner, onCleanup, peek, read, root, signal, untrack, write } from '~/system';
|
|
3
|
+
import type { Computed } from '~/system';
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
describe('untrack', () => {
|
|
@@ -72,6 +73,229 @@ describe('untrack', () => {
|
|
|
72
73
|
});
|
|
73
74
|
|
|
74
75
|
|
|
76
|
+
describe('untrack ownership', () => {
|
|
77
|
+
it('an effect created inside untrack during a re-run is disposed by the next re-run and by dispose', async () => {
|
|
78
|
+
let inner = signal(0),
|
|
79
|
+
innerRuns: string[] = [],
|
|
80
|
+
outer = signal(0),
|
|
81
|
+
stop = effect(() => {
|
|
82
|
+
let gen = read(outer);
|
|
83
|
+
|
|
84
|
+
untrack(() => {
|
|
85
|
+
effect(() => {
|
|
86
|
+
innerRuns.push(`${gen}:${read(inner)}`);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
write(outer, 1);
|
|
92
|
+
await Promise.resolve();
|
|
93
|
+
|
|
94
|
+
expect(innerRuns).toEqual(['0:0', '1:0']);
|
|
95
|
+
|
|
96
|
+
write(inner, 1);
|
|
97
|
+
await Promise.resolve();
|
|
98
|
+
|
|
99
|
+
// Only generation 1's inner effect is alive; generation 0's was disposed by the re-run
|
|
100
|
+
expect(innerRuns).toEqual(['0:0', '1:0', '1:1']);
|
|
101
|
+
|
|
102
|
+
stop();
|
|
103
|
+
write(inner, 2);
|
|
104
|
+
await Promise.resolve();
|
|
105
|
+
|
|
106
|
+
expect(innerRuns).toEqual(['0:0', '1:0', '1:1']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('onCleanup inside untrack runs on the next re-run and on dispose', async () => {
|
|
110
|
+
let log: number[] = [],
|
|
111
|
+
s = signal(0),
|
|
112
|
+
stop = effect(() => {
|
|
113
|
+
let v = read(s);
|
|
114
|
+
|
|
115
|
+
untrack(() => onCleanup(() => { log.push(v); }));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
write(s, 1);
|
|
119
|
+
await Promise.resolve();
|
|
120
|
+
|
|
121
|
+
expect(log).toEqual([0]);
|
|
122
|
+
|
|
123
|
+
stop();
|
|
124
|
+
|
|
125
|
+
expect(log).toEqual([0, 1]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('tears down untracked children and cleanups in registration order', async () => {
|
|
129
|
+
let log: string[] = [],
|
|
130
|
+
s = signal(0),
|
|
131
|
+
stop = effect(() => {
|
|
132
|
+
read(s);
|
|
133
|
+
onCleanup(() => { log.push('a'); });
|
|
134
|
+
untrack(() => {
|
|
135
|
+
effect(() => {
|
|
136
|
+
onCleanup(() => { log.push('b'); });
|
|
137
|
+
});
|
|
138
|
+
onCleanup(() => { log.push('c'); });
|
|
139
|
+
});
|
|
140
|
+
root((d) => {
|
|
141
|
+
onCleanup(() => { log.push('d'); });
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
write(s, 1);
|
|
146
|
+
await Promise.resolve();
|
|
147
|
+
|
|
148
|
+
expect(log).toEqual(['a', 'b', 'c', 'd']);
|
|
149
|
+
|
|
150
|
+
stop();
|
|
151
|
+
|
|
152
|
+
// dispose() drains owned children after the owner's own cleanups, exactly as for tracked children
|
|
153
|
+
expect(log.slice(4).sort()).toEqual(['a', 'b', 'c', 'd']);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('reads inside untrack still do not subscribe, nor do reads of untracked children', async () => {
|
|
157
|
+
let a = signal(0),
|
|
158
|
+
b = signal(0),
|
|
159
|
+
runs = 0;
|
|
160
|
+
|
|
161
|
+
effect(() => {
|
|
162
|
+
runs++;
|
|
163
|
+
untrack(() => {
|
|
164
|
+
read(a);
|
|
165
|
+
computed(() => read(b));
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
write(a, 1);
|
|
170
|
+
write(b, 1);
|
|
171
|
+
await Promise.resolve();
|
|
172
|
+
|
|
173
|
+
expect(runs).toBe(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('outside any computation creations stay unowned', () => {
|
|
177
|
+
let log: string[] = [],
|
|
178
|
+
stop = untrack(() => {
|
|
179
|
+
onCleanup(() => { log.push('dropped'); });
|
|
180
|
+
|
|
181
|
+
return effect(() => {
|
|
182
|
+
onCleanup(() => { log.push('effect'); });
|
|
183
|
+
});
|
|
184
|
+
}),
|
|
185
|
+
c = untrack(() => computed(() => 1)) as Computed<unknown>;
|
|
186
|
+
|
|
187
|
+
expect(c.owner).toBe(null);
|
|
188
|
+
|
|
189
|
+
stop();
|
|
190
|
+
|
|
191
|
+
expect(log).toEqual(['effect']);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('inside root((d) => ...) creations stay owned by the root', () => {
|
|
195
|
+
let log: string[] = [];
|
|
196
|
+
|
|
197
|
+
root((d) => {
|
|
198
|
+
untrack(() => {
|
|
199
|
+
onCleanup(() => { log.push('cleanup'); });
|
|
200
|
+
effect(() => {
|
|
201
|
+
onCleanup(() => { log.push('effect'); });
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
d();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(log).toEqual(['cleanup', 'effect']);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('nested untrack keeps the running computation as owner', async () => {
|
|
211
|
+
let log: number[] = [],
|
|
212
|
+
s = signal(0),
|
|
213
|
+
stop = effect(() => {
|
|
214
|
+
let v = read(s);
|
|
215
|
+
|
|
216
|
+
untrack(() => untrack(() => onCleanup(() => { log.push(v); })));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
write(s, 1);
|
|
220
|
+
await Promise.resolve();
|
|
221
|
+
stop();
|
|
222
|
+
|
|
223
|
+
expect(log).toEqual([0, 1]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('a zero-arg root inside untrack stays detached', async () => {
|
|
227
|
+
let log: number[] = [],
|
|
228
|
+
s = signal(0),
|
|
229
|
+
stop = effect(() => {
|
|
230
|
+
let v = read(s);
|
|
231
|
+
|
|
232
|
+
untrack(() => root(() => {
|
|
233
|
+
onCleanup(() => { log.push(-1); });
|
|
234
|
+
untrack(() => effect(() => {
|
|
235
|
+
onCleanup(() => { log.push(v); });
|
|
236
|
+
}));
|
|
237
|
+
}));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
write(s, 1);
|
|
241
|
+
await Promise.resolve();
|
|
242
|
+
stop();
|
|
243
|
+
|
|
244
|
+
// Neither the root's cleanup (no owner) nor its effects are torn down by the outer effect
|
|
245
|
+
expect(log).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('restores owner and observer when fn throws', async () => {
|
|
249
|
+
let a = signal(0),
|
|
250
|
+
runs = 0;
|
|
251
|
+
|
|
252
|
+
effect(() => {
|
|
253
|
+
runs++;
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
untrack(() => {
|
|
257
|
+
throw new Error('boom');
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// only restoration is under test
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
read(a);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(untrack(() => hasOwner())).toBe(false);
|
|
268
|
+
|
|
269
|
+
write(a, 1);
|
|
270
|
+
await Promise.resolve();
|
|
271
|
+
|
|
272
|
+
expect(runs).toBe(2);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('an async computed created inside untrack is owned by the running computation', async () => {
|
|
276
|
+
let log: string[] = [],
|
|
277
|
+
s = signal(0),
|
|
278
|
+
stop = effect(() => {
|
|
279
|
+
let v = read(s);
|
|
280
|
+
|
|
281
|
+
untrack(() => computed(async () => {
|
|
282
|
+
onCleanup(() => { log.push(`factory ${v}`); });
|
|
283
|
+
return v;
|
|
284
|
+
}));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
write(s, 1);
|
|
288
|
+
await Promise.resolve();
|
|
289
|
+
|
|
290
|
+
expect(log).toEqual(['factory 0']);
|
|
291
|
+
|
|
292
|
+
stop();
|
|
293
|
+
|
|
294
|
+
expect(log).toEqual(['factory 0', 'factory 1']);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
|
|
75
299
|
describe('peek', () => {
|
|
76
300
|
it('returns the current value of a signal', () => {
|
|
77
301
|
let s = signal(5);
|