@cplieger/reactive 1.0.0 → 1.0.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/jsr.json +8 -12
- package/package.json +6 -2
- package/src/invariants.test.ts +483 -0
- package/src/reactive.test.ts +1571 -0
- package/src/redteam.test.ts +552 -0
- package/src/redteam2.test.ts +711 -0
- package/src/redteam3.test.ts +771 -0
- package/src/redteam4.test.ts +410 -0
- package/src/redteam5.test.ts +692 -0
- package/src/redteam6.test.ts +650 -0
- package/src/redteam7.test.ts +658 -0
- package/src/redteam8-adversarial.test.ts +1084 -0
- package/.editorconfig +0 -19
- package/.htmlvalidate.json +0 -28
- package/.stylelintrc.json +0 -67
- package/cliff.toml +0 -37
- package/eslint.config.base.mjs +0 -187
package/jsr.json
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/reactive",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"exports": {
|
|
5
5
|
".": "./src/index.ts"
|
|
6
6
|
},
|
|
7
7
|
"publish": {
|
|
8
|
+
"include": [
|
|
9
|
+
"LICENSE",
|
|
10
|
+
"README.md",
|
|
11
|
+
"src/**/*.ts"
|
|
12
|
+
],
|
|
8
13
|
"exclude": [
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"**/fc-strict-setup.ts",
|
|
12
|
-
"eslint.config.mjs",
|
|
13
|
-
"tsconfig.json",
|
|
14
|
-
"tsconfig.test.json",
|
|
15
|
-
"tsconfig.tests.json",
|
|
16
|
-
"vitest.config.ts",
|
|
17
|
-
"renovate.json",
|
|
18
|
-
"package-lock.json",
|
|
19
|
-
".prettierrc.json"
|
|
14
|
+
"src/**/*.test.ts",
|
|
15
|
+
"src/**/__test-helpers__/**"
|
|
20
16
|
]
|
|
21
17
|
}
|
|
22
18
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/reactive",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"exports": {
|
|
@@ -31,5 +31,9 @@
|
|
|
31
31
|
"repository": {
|
|
32
32
|
"type": "git",
|
|
33
33
|
"url": "git+https://github.com/cplieger/reactive.git"
|
|
34
|
-
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src/",
|
|
37
|
+
"jsr.json"
|
|
38
|
+
]
|
|
35
39
|
}
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Tests for new core invariants: glitch-freedom, exception-safety, dispose/leak,
|
|
3
|
+
// batch cycle-detection. Added as part of the Preact-style pull-based rewrite.
|
|
4
|
+
import { describe, it, expect, vi } from "vitest";
|
|
5
|
+
import { signal, effect, batch, computed, untracked, setEffectErrorHandler } from "./index.js";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// 1. Glitch-freedom: pull-based refresh guarantees consistent reads
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
describe("glitch-freedom", () => {
|
|
11
|
+
it("diamond: dependent fires exactly once with consistent inputs", () => {
|
|
12
|
+
// Graph: A → B, A → C, B+C → D
|
|
13
|
+
const a = signal(1);
|
|
14
|
+
const b = computed(() => a.value * 2);
|
|
15
|
+
const c = computed(() => a.value + 10);
|
|
16
|
+
const d = computed(() => b.value + c.value);
|
|
17
|
+
const spy = vi.fn();
|
|
18
|
+
effect(() => {
|
|
19
|
+
spy(d.value);
|
|
20
|
+
});
|
|
21
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
22
|
+
expect(spy).toHaveBeenLastCalledWith(13); // 2 + 11
|
|
23
|
+
spy.mockClear();
|
|
24
|
+
a.value = 5;
|
|
25
|
+
// D should fire exactly once with consistent b=10, c=15
|
|
26
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
27
|
+
expect(spy).toHaveBeenLastCalledWith(25); // 10 + 15
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("deep chain: single propagation through 10-level computed chain", () => {
|
|
31
|
+
const s = signal(0);
|
|
32
|
+
const chain: ReturnType<typeof computed<number>>[] = [];
|
|
33
|
+
let prev: { readonly value: number } = s;
|
|
34
|
+
for (let i = 0; i < 10; i++) {
|
|
35
|
+
const src = prev;
|
|
36
|
+
const c = computed(() => src.value + 1);
|
|
37
|
+
chain.push(c);
|
|
38
|
+
prev = c;
|
|
39
|
+
}
|
|
40
|
+
const spy = vi.fn();
|
|
41
|
+
effect(() => {
|
|
42
|
+
spy(prev.value);
|
|
43
|
+
});
|
|
44
|
+
expect(spy).toHaveBeenCalledWith(10);
|
|
45
|
+
spy.mockClear();
|
|
46
|
+
s.value = 1;
|
|
47
|
+
// Effect fires exactly once with correct end value
|
|
48
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
49
|
+
expect(spy).toHaveBeenCalledWith(11);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("wide fan-out: one signal → 50 effects, each fires once", () => {
|
|
53
|
+
const s = signal(0);
|
|
54
|
+
const spies: ReturnType<typeof vi.fn>[] = [];
|
|
55
|
+
for (let i = 0; i < 50; i++) {
|
|
56
|
+
const spy = vi.fn();
|
|
57
|
+
spies.push(spy);
|
|
58
|
+
effect(() => {
|
|
59
|
+
spy(s.value);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
for (const spy of spies) {
|
|
63
|
+
spy.mockClear();
|
|
64
|
+
}
|
|
65
|
+
s.value = 1;
|
|
66
|
+
for (const spy of spies) {
|
|
67
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(spy).toHaveBeenCalledWith(1);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("diamond with conditional dep: no glitch when dep is dropped", () => {
|
|
73
|
+
const a = signal(1);
|
|
74
|
+
const cond = signal(true);
|
|
75
|
+
const b = computed(() => a.value * 2);
|
|
76
|
+
const c = computed(() => (cond.value ? b.value : 0));
|
|
77
|
+
const spy = vi.fn();
|
|
78
|
+
effect(() => {
|
|
79
|
+
spy(c.value);
|
|
80
|
+
});
|
|
81
|
+
expect(spy).toHaveBeenLastCalledWith(2);
|
|
82
|
+
spy.mockClear();
|
|
83
|
+
// Drop dep on b
|
|
84
|
+
cond.value = false;
|
|
85
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
86
|
+
expect(spy).toHaveBeenLastCalledWith(0);
|
|
87
|
+
spy.mockClear();
|
|
88
|
+
// Changing a should NOT trigger c (b is no longer a dep)
|
|
89
|
+
a.value = 100;
|
|
90
|
+
expect(spy).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("multi-diamond: two shared sources converging", () => {
|
|
94
|
+
const x = signal(1);
|
|
95
|
+
const y = signal(2);
|
|
96
|
+
const a = computed(() => x.value + y.value); // 3
|
|
97
|
+
const b = computed(() => x.value * y.value); // 2
|
|
98
|
+
const c = computed(() => a.value + b.value); // 5
|
|
99
|
+
const spy = vi.fn();
|
|
100
|
+
effect(() => {
|
|
101
|
+
spy(c.value);
|
|
102
|
+
});
|
|
103
|
+
expect(spy).toHaveBeenLastCalledWith(5);
|
|
104
|
+
spy.mockClear();
|
|
105
|
+
batch(() => {
|
|
106
|
+
x.value = 3;
|
|
107
|
+
y.value = 4;
|
|
108
|
+
});
|
|
109
|
+
// a=7, b=12, c=19 — effect fires once
|
|
110
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(spy).toHaveBeenLastCalledWith(19);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// 2. Exception-safety: errors never corrupt the graph
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
describe("exception-safety", () => {
|
|
119
|
+
it("effect body throw: other effects still run, later flush works", () => {
|
|
120
|
+
const prev = setEffectErrorHandler(() => {
|
|
121
|
+
/* swallow */
|
|
122
|
+
});
|
|
123
|
+
const s = signal(0);
|
|
124
|
+
const spy1 = vi.fn();
|
|
125
|
+
const spy2 = vi.fn();
|
|
126
|
+
effect(() => {
|
|
127
|
+
if (s.value > 0) {
|
|
128
|
+
throw new Error("boom");
|
|
129
|
+
}
|
|
130
|
+
spy1(s.value);
|
|
131
|
+
});
|
|
132
|
+
effect(() => {
|
|
133
|
+
spy2(s.value);
|
|
134
|
+
});
|
|
135
|
+
spy1.mockClear();
|
|
136
|
+
spy2.mockClear();
|
|
137
|
+
s.value = 1;
|
|
138
|
+
// spy1 threw, but spy2 should still fire
|
|
139
|
+
expect(spy2).toHaveBeenCalledWith(1);
|
|
140
|
+
// Now recover
|
|
141
|
+
s.value = 0;
|
|
142
|
+
expect(spy1).toHaveBeenCalledWith(0);
|
|
143
|
+
setEffectErrorHandler(prev);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("cleanup throw: effect still re-runs, no stuck flags", () => {
|
|
147
|
+
const prev = setEffectErrorHandler(() => {
|
|
148
|
+
/* swallow */
|
|
149
|
+
});
|
|
150
|
+
const s = signal(0);
|
|
151
|
+
const spy = vi.fn();
|
|
152
|
+
let throwInCleanup = false;
|
|
153
|
+
effect(() => {
|
|
154
|
+
spy(s.value);
|
|
155
|
+
return () => {
|
|
156
|
+
if (throwInCleanup) {
|
|
157
|
+
throw new Error("cleanup-boom");
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
spy.mockClear();
|
|
162
|
+
throwInCleanup = true;
|
|
163
|
+
s.value = 1;
|
|
164
|
+
// Effect still runs despite cleanup throw
|
|
165
|
+
expect(spy).toHaveBeenCalledWith(1);
|
|
166
|
+
throwInCleanup = false;
|
|
167
|
+
s.value = 2;
|
|
168
|
+
expect(spy).toHaveBeenCalledWith(2);
|
|
169
|
+
setEffectErrorHandler(prev);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("computed fn throw: cached and re-thrown, recovers on next change", () => {
|
|
173
|
+
const s = signal(0);
|
|
174
|
+
let shouldThrow = false;
|
|
175
|
+
const c = computed(() => {
|
|
176
|
+
if (shouldThrow) {
|
|
177
|
+
throw new Error("comp-err");
|
|
178
|
+
}
|
|
179
|
+
return s.value * 2;
|
|
180
|
+
});
|
|
181
|
+
expect(c.value).toBe(0);
|
|
182
|
+
shouldThrow = true;
|
|
183
|
+
s.value = 1;
|
|
184
|
+
expect(() => c.value).toThrow("comp-err");
|
|
185
|
+
// Recover
|
|
186
|
+
shouldThrow = false;
|
|
187
|
+
s.value = 2;
|
|
188
|
+
expect(c.value).toBe(4);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("user equals throw: successful value stored, no HAS_ERROR poisoning (F2)", () => {
|
|
192
|
+
let throwInEquals = false;
|
|
193
|
+
const s = signal(0);
|
|
194
|
+
const c = computed(() => s.value * 2, {
|
|
195
|
+
equals: (_a, _b) => {
|
|
196
|
+
if (throwInEquals) {
|
|
197
|
+
throw new Error("eq-boom");
|
|
198
|
+
}
|
|
199
|
+
return false; // always changed
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
expect(c.value).toBe(0);
|
|
203
|
+
throwInEquals = true;
|
|
204
|
+
s.value = 5;
|
|
205
|
+
// Despite equals throwing, the value should be the new successful computation
|
|
206
|
+
expect(c.value).toBe(10);
|
|
207
|
+
throwInEquals = false;
|
|
208
|
+
s.value = 6;
|
|
209
|
+
expect(c.value).toBe(12);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("multiple effects throw mid-flush: all execute, system stable after", () => {
|
|
213
|
+
const errors: unknown[] = [];
|
|
214
|
+
const prev = setEffectErrorHandler((e) => {
|
|
215
|
+
errors.push(e);
|
|
216
|
+
});
|
|
217
|
+
const s = signal(0);
|
|
218
|
+
const results: number[] = [];
|
|
219
|
+
for (let i = 0; i < 5; i++) {
|
|
220
|
+
const idx = i;
|
|
221
|
+
effect(() => {
|
|
222
|
+
if (s.value > 0 && idx % 2 === 0) {
|
|
223
|
+
throw new Error(`err-${idx}`);
|
|
224
|
+
}
|
|
225
|
+
results.push(s.value + idx);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
results.length = 0;
|
|
229
|
+
s.value = 1;
|
|
230
|
+
// Odd-indexed effects should have run successfully
|
|
231
|
+
expect(results).toContain(2); // idx=1: 1+1
|
|
232
|
+
expect(results).toContain(4); // idx=3: 1+3
|
|
233
|
+
// System still works
|
|
234
|
+
s.value = 0;
|
|
235
|
+
expect(results).toContain(0);
|
|
236
|
+
setEffectErrorHandler(prev);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("exception in computed does not prevent other downstream effects from running", () => {
|
|
240
|
+
const prev = setEffectErrorHandler(() => {
|
|
241
|
+
/* swallow */
|
|
242
|
+
});
|
|
243
|
+
const s = signal(1);
|
|
244
|
+
let throwInC = false;
|
|
245
|
+
const c = computed(() => {
|
|
246
|
+
if (throwInC) {
|
|
247
|
+
throw new Error("c-err");
|
|
248
|
+
}
|
|
249
|
+
return s.value;
|
|
250
|
+
});
|
|
251
|
+
const spy = vi.fn();
|
|
252
|
+
// Effect that reads c (will catch error)
|
|
253
|
+
effect(() => {
|
|
254
|
+
try {
|
|
255
|
+
spy(c.value);
|
|
256
|
+
} catch {
|
|
257
|
+
spy("error");
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
// Another independent effect
|
|
261
|
+
const spy2 = vi.fn();
|
|
262
|
+
effect(() => {
|
|
263
|
+
spy2(s.value);
|
|
264
|
+
});
|
|
265
|
+
spy.mockClear();
|
|
266
|
+
spy2.mockClear();
|
|
267
|
+
throwInC = true;
|
|
268
|
+
s.value = 2;
|
|
269
|
+
expect(spy).toHaveBeenCalledWith("error");
|
|
270
|
+
expect(spy2).toHaveBeenCalledWith(2);
|
|
271
|
+
setEffectErrorHandler(prev);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// 3. Dispose/leak: disposed nodes fully unlink, closures nulled (F5)
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
describe("dispose/leak", () => {
|
|
279
|
+
it("disposed effect does not re-run on signal change", () => {
|
|
280
|
+
const s = signal(0);
|
|
281
|
+
const spy = vi.fn();
|
|
282
|
+
const dispose = effect(() => {
|
|
283
|
+
spy(s.value);
|
|
284
|
+
});
|
|
285
|
+
spy.mockClear();
|
|
286
|
+
dispose();
|
|
287
|
+
s.value = 1;
|
|
288
|
+
expect(spy).not.toHaveBeenCalled();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("disposed effect runs cleanup exactly once", () => {
|
|
292
|
+
const cleanupSpy = vi.fn();
|
|
293
|
+
const s = signal(0);
|
|
294
|
+
const dispose = effect(() => {
|
|
295
|
+
void s.value;
|
|
296
|
+
return cleanupSpy;
|
|
297
|
+
});
|
|
298
|
+
dispose();
|
|
299
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
|
300
|
+
// Double dispose is no-op
|
|
301
|
+
dispose();
|
|
302
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("F5: disposeEffect nulls _fn and _cleanup to release closures", () => {
|
|
306
|
+
// We can't directly access internals, but we verify via WeakRef
|
|
307
|
+
const s = signal(0);
|
|
308
|
+
const myFn = () => {
|
|
309
|
+
void s.value;
|
|
310
|
+
return myCleanup;
|
|
311
|
+
};
|
|
312
|
+
const myCleanup = () => {
|
|
313
|
+
/* cleanup */
|
|
314
|
+
};
|
|
315
|
+
const fnRef = new WeakRef(myFn);
|
|
316
|
+
const cleanupRef = new WeakRef(myCleanup);
|
|
317
|
+
const dispose = effect(myFn);
|
|
318
|
+
dispose();
|
|
319
|
+
// After dispose, effect should not hold references to fn/cleanup
|
|
320
|
+
// We can at least verify the dispose completed without error
|
|
321
|
+
expect(fnRef.deref()).toBeDefined(); // Still in our scope
|
|
322
|
+
expect(cleanupRef.deref()).toBeDefined();
|
|
323
|
+
s.value = 1; // No re-run
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("disposed effect mid-batch: not re-triggered", () => {
|
|
327
|
+
const s = signal(0);
|
|
328
|
+
const spy = vi.fn();
|
|
329
|
+
const dispose = effect(() => {
|
|
330
|
+
spy(s.value);
|
|
331
|
+
});
|
|
332
|
+
spy.mockClear();
|
|
333
|
+
batch(() => {
|
|
334
|
+
s.value = 1;
|
|
335
|
+
dispose();
|
|
336
|
+
s.value = 2;
|
|
337
|
+
});
|
|
338
|
+
expect(spy).not.toHaveBeenCalled();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("many effects created and disposed: no accumulating edges", () => {
|
|
342
|
+
const s = signal(0);
|
|
343
|
+
const disposers: (() => void)[] = [];
|
|
344
|
+
for (let i = 0; i < 100; i++) {
|
|
345
|
+
disposers.push(
|
|
346
|
+
effect(() => {
|
|
347
|
+
void s.value;
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
for (const d of disposers) {
|
|
352
|
+
d();
|
|
353
|
+
}
|
|
354
|
+
// Signal should have no targets after all effects disposed
|
|
355
|
+
const spy = vi.fn();
|
|
356
|
+
effect(() => {
|
|
357
|
+
spy(s.value);
|
|
358
|
+
});
|
|
359
|
+
spy.mockClear();
|
|
360
|
+
s.value = 1;
|
|
361
|
+
// Only the one remaining effect fires
|
|
362
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// 4. Batch cycle-detection (F8)
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
describe("batch cycle-detection", () => {
|
|
370
|
+
it("throws when effect re-triggers itself >100 times", () => {
|
|
371
|
+
const prev = setEffectErrorHandler(() => {
|
|
372
|
+
/* swallow */
|
|
373
|
+
});
|
|
374
|
+
const s = signal(0);
|
|
375
|
+
// This effect writes to s on every run, creating an infinite loop
|
|
376
|
+
effect(() => {
|
|
377
|
+
if (s.value < 200) {
|
|
378
|
+
s.value = s.value + 1;
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
// The cycle detection should have fired during initial run
|
|
382
|
+
// (effect runs, writes s, triggers itself, repeat)
|
|
383
|
+
// After 100 iterations endBatch throws
|
|
384
|
+
setEffectErrorHandler(prev);
|
|
385
|
+
// Verify system is still usable after cycle detection
|
|
386
|
+
const spy = vi.fn();
|
|
387
|
+
const s2 = signal(0);
|
|
388
|
+
effect(() => {
|
|
389
|
+
spy(s2.value);
|
|
390
|
+
});
|
|
391
|
+
spy.mockClear();
|
|
392
|
+
s2.value = 42;
|
|
393
|
+
expect(spy).toHaveBeenCalledWith(42);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("nested batches don't false-trigger cycle detection", () => {
|
|
397
|
+
const s = signal(0);
|
|
398
|
+
const spy = vi.fn();
|
|
399
|
+
effect(() => {
|
|
400
|
+
spy(s.value);
|
|
401
|
+
});
|
|
402
|
+
spy.mockClear();
|
|
403
|
+
batch(() => {
|
|
404
|
+
batch(() => {
|
|
405
|
+
batch(() => {
|
|
406
|
+
s.value = 1;
|
|
407
|
+
});
|
|
408
|
+
s.value = 2;
|
|
409
|
+
});
|
|
410
|
+
s.value = 3;
|
|
411
|
+
});
|
|
412
|
+
// Only final value visible
|
|
413
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
414
|
+
expect(spy).toHaveBeenCalledWith(3);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// 5. peek() semantics (F6)
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
describe("peek", () => {
|
|
422
|
+
it("peek() triggers refresh without tracking", () => {
|
|
423
|
+
const s = signal(5);
|
|
424
|
+
const c = computed(() => s.value * 3);
|
|
425
|
+
const spy = vi.fn();
|
|
426
|
+
effect(() => {
|
|
427
|
+
// Use peek on c — should NOT create a subscription
|
|
428
|
+
spy(c.peek());
|
|
429
|
+
});
|
|
430
|
+
expect(spy).toHaveBeenCalledWith(15);
|
|
431
|
+
spy.mockClear();
|
|
432
|
+
s.value = 10;
|
|
433
|
+
// Effect should NOT re-run because peek doesn't track
|
|
434
|
+
expect(spy).not.toHaveBeenCalled();
|
|
435
|
+
// But peek still returns fresh value
|
|
436
|
+
expect(c.peek()).toBe(30);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("signal peek() doesn't track", () => {
|
|
440
|
+
const s = signal(1);
|
|
441
|
+
const spy = vi.fn();
|
|
442
|
+
effect(() => {
|
|
443
|
+
spy(s.peek());
|
|
444
|
+
});
|
|
445
|
+
spy.mockClear();
|
|
446
|
+
s.value = 2;
|
|
447
|
+
expect(spy).not.toHaveBeenCalled();
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
// 6. untracked helper
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
describe("untracked in new core", () => {
|
|
455
|
+
it("untracked reads don't create subscriptions", () => {
|
|
456
|
+
const s = signal(0);
|
|
457
|
+
const spy = vi.fn();
|
|
458
|
+
effect(() => {
|
|
459
|
+
spy(untracked(() => s.value));
|
|
460
|
+
});
|
|
461
|
+
spy.mockClear();
|
|
462
|
+
s.value = 1;
|
|
463
|
+
expect(spy).not.toHaveBeenCalled();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("untracked restores context on throw", () => {
|
|
467
|
+
const s = signal(0);
|
|
468
|
+
const spy = vi.fn();
|
|
469
|
+
effect(() => {
|
|
470
|
+
try {
|
|
471
|
+
untracked(() => {
|
|
472
|
+
throw new Error("x");
|
|
473
|
+
});
|
|
474
|
+
} catch {
|
|
475
|
+
// swallow
|
|
476
|
+
}
|
|
477
|
+
spy(s.value); // This should track s
|
|
478
|
+
});
|
|
479
|
+
spy.mockClear();
|
|
480
|
+
s.value = 1;
|
|
481
|
+
expect(spy).toHaveBeenCalledWith(1);
|
|
482
|
+
});
|
|
483
|
+
});
|