@esportsplus/reactivity 0.39.0 → 0.39.1

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
@@ -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
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "type": "module",
38
38
  "types": "build/index.d.ts",
39
- "version": "0.39.0",
39
+ "version": "0.39.1",
40
40
  "scripts": {
41
41
  "agent:bench": "vitest bench --run",
42
42
  "agent:test": "tsc --noEmit && vitest run",
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 or a root's scope)
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
 
@@ -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
 
@@ -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);