@hyperfixi/reactivity 2.4.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/LICENSE +20 -0
- package/README.md +137 -0
- package/dist/index.cjs +772 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +282 -0
- package/dist/index.d.ts +282 -0
- package/dist/index.js +744 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/bind.ts +355 -0
- package/src/caret-var.test.ts +137 -0
- package/src/caret-var.ts +125 -0
- package/src/index.ts +132 -0
- package/src/integration.test.ts +585 -0
- package/src/live.ts +68 -0
- package/src/signals.test.ts +369 -0
- package/src/signals.ts +444 -0
- package/src/types.ts +46 -0
- package/src/when.ts +72 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal/effect runtime unit tests — subscribe/notify, microtask batching,
|
|
3
|
+
* cycle detection, Object.is semantics, stop().
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
7
|
+
import { Reactive } from './signals';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Settle the effect scheduler. `createEffect()` defers its first run via
|
|
11
|
+
* `queueMicrotask`, and that first run is itself async (awaits the expression,
|
|
12
|
+
* then awaits the handler). A single tick isn't enough; we flush several
|
|
13
|
+
* rounds plus a `setTimeout(0)` fallback to drain the whole queue.
|
|
14
|
+
*/
|
|
15
|
+
async function settle(): Promise<void> {
|
|
16
|
+
for (let i = 0; i < 10; i++) {
|
|
17
|
+
await Promise.resolve();
|
|
18
|
+
}
|
|
19
|
+
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
20
|
+
for (let i = 0; i < 10; i++) {
|
|
21
|
+
await Promise.resolve();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const tick = settle;
|
|
25
|
+
|
|
26
|
+
describe('signals.ts — Reactive core', () => {
|
|
27
|
+
let r: Reactive;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
r = new Reactive();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('global dependency tracking', () => {
|
|
34
|
+
it('subscribes an effect to a global read and re-runs on notify', async () => {
|
|
35
|
+
let handlerValue: unknown = null;
|
|
36
|
+
const counter = { v: 0 };
|
|
37
|
+
r.createEffect(
|
|
38
|
+
() => {
|
|
39
|
+
r.trackGlobal('count');
|
|
40
|
+
return counter.v;
|
|
41
|
+
},
|
|
42
|
+
v => {
|
|
43
|
+
handlerValue = v;
|
|
44
|
+
},
|
|
45
|
+
null
|
|
46
|
+
);
|
|
47
|
+
await tick();
|
|
48
|
+
expect(handlerValue).toBe(0);
|
|
49
|
+
|
|
50
|
+
counter.v = 5;
|
|
51
|
+
r.notifyGlobal('count');
|
|
52
|
+
await tick();
|
|
53
|
+
await tick();
|
|
54
|
+
expect(handlerValue).toBe(5);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('coalesces multiple synchronous writes into a single handler call', async () => {
|
|
58
|
+
let callCount = 0;
|
|
59
|
+
const counter = { v: 0 };
|
|
60
|
+
r.createEffect(
|
|
61
|
+
() => {
|
|
62
|
+
r.trackGlobal('x');
|
|
63
|
+
return counter.v;
|
|
64
|
+
},
|
|
65
|
+
_v => {
|
|
66
|
+
callCount++;
|
|
67
|
+
},
|
|
68
|
+
null
|
|
69
|
+
);
|
|
70
|
+
await tick();
|
|
71
|
+
expect(callCount).toBe(1); // initial
|
|
72
|
+
|
|
73
|
+
counter.v = 1;
|
|
74
|
+
r.notifyGlobal('x');
|
|
75
|
+
counter.v = 2;
|
|
76
|
+
r.notifyGlobal('x');
|
|
77
|
+
counter.v = 3;
|
|
78
|
+
r.notifyGlobal('x');
|
|
79
|
+
await tick();
|
|
80
|
+
await tick();
|
|
81
|
+
expect(callCount).toBe(2); // batched into a single run
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('Object.is semantics', () => {
|
|
86
|
+
it('skips handler when new value is Object.is-equal to previous', async () => {
|
|
87
|
+
let calls = 0;
|
|
88
|
+
const counter = { v: 42 };
|
|
89
|
+
r.createEffect(
|
|
90
|
+
() => {
|
|
91
|
+
r.trackGlobal('x');
|
|
92
|
+
return counter.v;
|
|
93
|
+
},
|
|
94
|
+
_v => {
|
|
95
|
+
calls++;
|
|
96
|
+
},
|
|
97
|
+
null
|
|
98
|
+
);
|
|
99
|
+
await tick();
|
|
100
|
+
expect(calls).toBe(1);
|
|
101
|
+
|
|
102
|
+
// Notify without actually changing the value.
|
|
103
|
+
r.notifyGlobal('x');
|
|
104
|
+
await tick();
|
|
105
|
+
await tick();
|
|
106
|
+
expect(calls).toBe(1); // handler not called again
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('treats NaN !== NaN as equal (Object.is)', async () => {
|
|
110
|
+
let calls = 0;
|
|
111
|
+
const store = { v: NaN };
|
|
112
|
+
r.createEffect(
|
|
113
|
+
() => {
|
|
114
|
+
r.trackGlobal('n');
|
|
115
|
+
return store.v;
|
|
116
|
+
},
|
|
117
|
+
_v => {
|
|
118
|
+
calls++;
|
|
119
|
+
},
|
|
120
|
+
null
|
|
121
|
+
);
|
|
122
|
+
await tick();
|
|
123
|
+
// NaN initial is not undefined/null so handler DOES fire once.
|
|
124
|
+
expect(calls).toBe(1);
|
|
125
|
+
|
|
126
|
+
store.v = NaN; // same NaN
|
|
127
|
+
r.notifyGlobal('n');
|
|
128
|
+
await tick();
|
|
129
|
+
await tick();
|
|
130
|
+
expect(calls).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('cycle detection', () => {
|
|
135
|
+
it('halts a self-triggering effect before running away', async () => {
|
|
136
|
+
const origError = console.error;
|
|
137
|
+
console.error = () => {
|
|
138
|
+
/* swallow expected cycle warning */
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
let runCount = 0;
|
|
142
|
+
r.createEffect(
|
|
143
|
+
() => {
|
|
144
|
+
r.trackGlobal('loop');
|
|
145
|
+
runCount++;
|
|
146
|
+
return runCount;
|
|
147
|
+
},
|
|
148
|
+
_v => {
|
|
149
|
+
// Cycle: each handler run schedules another flush for the same effect.
|
|
150
|
+
r.notifyGlobal('loop');
|
|
151
|
+
},
|
|
152
|
+
null
|
|
153
|
+
);
|
|
154
|
+
// Yield the loop long enough for the guard to trip. Each effect run
|
|
155
|
+
// needs several microtasks (flush → run → _runWithEffect → handler),
|
|
156
|
+
// so we pump many rounds.
|
|
157
|
+
for (let i = 0; i < 1000; i++) await Promise.resolve();
|
|
158
|
+
// Guard should fire around runCount=101 (init + 100 cycled runs).
|
|
159
|
+
// After halt, no further runs should occur.
|
|
160
|
+
const stabilized = runCount;
|
|
161
|
+
for (let i = 0; i < 200; i++) await Promise.resolve();
|
|
162
|
+
expect(runCount).toBe(stabilized);
|
|
163
|
+
expect(runCount).toBeGreaterThanOrEqual(50);
|
|
164
|
+
expect(runCount).toBeLessThan(500);
|
|
165
|
+
} finally {
|
|
166
|
+
console.error = origError;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('cycle counter reset across flushes', () => {
|
|
172
|
+
it('long-lived effect survives many separate notifies', async () => {
|
|
173
|
+
let calls = 0;
|
|
174
|
+
const counter = { v: 0 };
|
|
175
|
+
r.createEffect(
|
|
176
|
+
() => {
|
|
177
|
+
r.trackGlobal('x');
|
|
178
|
+
return counter.v;
|
|
179
|
+
},
|
|
180
|
+
_v => {
|
|
181
|
+
calls++;
|
|
182
|
+
},
|
|
183
|
+
null
|
|
184
|
+
);
|
|
185
|
+
await tick();
|
|
186
|
+
expect(calls).toBe(1);
|
|
187
|
+
|
|
188
|
+
// 200 separate notifications, each in its own flush. The cycle counter
|
|
189
|
+
// must reset between them or the effect halts at ~100.
|
|
190
|
+
for (let i = 1; i <= 200; i++) {
|
|
191
|
+
counter.v = i;
|
|
192
|
+
r.notifyGlobal('x');
|
|
193
|
+
await tick();
|
|
194
|
+
}
|
|
195
|
+
// Initial run + 200 notifications.
|
|
196
|
+
expect(calls).toBe(201);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('re-entrant flush batching', () => {
|
|
201
|
+
it('cascading writes inside a handler fire each effect once per outer write', async () => {
|
|
202
|
+
const xStore = { v: 0 };
|
|
203
|
+
const yStore = { v: 0 };
|
|
204
|
+
let aRuns = 0;
|
|
205
|
+
let bRuns = 0;
|
|
206
|
+
|
|
207
|
+
// Effect A reads x; on change, writes y.
|
|
208
|
+
r.createEffect(
|
|
209
|
+
() => {
|
|
210
|
+
r.trackGlobal('x');
|
|
211
|
+
return xStore.v;
|
|
212
|
+
},
|
|
213
|
+
v => {
|
|
214
|
+
aRuns++;
|
|
215
|
+
yStore.v = (v as number) * 10;
|
|
216
|
+
r.notifyGlobal('y');
|
|
217
|
+
},
|
|
218
|
+
null
|
|
219
|
+
);
|
|
220
|
+
// Effect B reads y; on change, increments bRuns.
|
|
221
|
+
r.createEffect(
|
|
222
|
+
() => {
|
|
223
|
+
r.trackGlobal('y');
|
|
224
|
+
return yStore.v;
|
|
225
|
+
},
|
|
226
|
+
_v => {
|
|
227
|
+
bRuns++;
|
|
228
|
+
},
|
|
229
|
+
null
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
await tick();
|
|
233
|
+
// Initial pass: A reads 0 (handler skipped due to undefined-guard? Actually 0 fires).
|
|
234
|
+
// Both effects' handlers fire on init for the truthy/non-undefined initial values.
|
|
235
|
+
expect(aRuns).toBe(1);
|
|
236
|
+
expect(bRuns).toBe(1);
|
|
237
|
+
|
|
238
|
+
xStore.v = 5;
|
|
239
|
+
r.notifyGlobal('x');
|
|
240
|
+
await tick();
|
|
241
|
+
await tick();
|
|
242
|
+
|
|
243
|
+
// A re-runs once (x changed 0 → 5), then synchronously notifies y.
|
|
244
|
+
// B re-runs once for the y change (0 → 50). No duplicate runs.
|
|
245
|
+
expect(aRuns).toBe(2);
|
|
246
|
+
expect(bRuns).toBe(2);
|
|
247
|
+
expect(yStore.v).toBe(50);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('stop()', () => {
|
|
252
|
+
it('removes an effect from subscription sets', async () => {
|
|
253
|
+
let calls = 0;
|
|
254
|
+
const counter = { v: 0 };
|
|
255
|
+
const stop = r.createEffect(
|
|
256
|
+
() => {
|
|
257
|
+
r.trackGlobal('x');
|
|
258
|
+
return counter.v;
|
|
259
|
+
},
|
|
260
|
+
_v => {
|
|
261
|
+
calls++;
|
|
262
|
+
},
|
|
263
|
+
null
|
|
264
|
+
);
|
|
265
|
+
await tick();
|
|
266
|
+
expect(calls).toBe(1);
|
|
267
|
+
|
|
268
|
+
stop();
|
|
269
|
+
counter.v = 42;
|
|
270
|
+
r.notifyGlobal('x');
|
|
271
|
+
await tick();
|
|
272
|
+
await tick();
|
|
273
|
+
expect(calls).toBe(1); // stopped — handler not called
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('element-scoped dependencies', () => {
|
|
278
|
+
it('notifies on element-scoped write and skips on other elements', async () => {
|
|
279
|
+
const a = document.createElement('div');
|
|
280
|
+
const b = document.createElement('div');
|
|
281
|
+
// Attach to document so isConnected checks pass.
|
|
282
|
+
document.body.appendChild(a);
|
|
283
|
+
document.body.appendChild(b);
|
|
284
|
+
let aCalls = 0;
|
|
285
|
+
let bCalls = 0;
|
|
286
|
+
const aStore = { v: 0 };
|
|
287
|
+
|
|
288
|
+
r.createEffect(
|
|
289
|
+
() => {
|
|
290
|
+
r.trackElement(a, 'flag');
|
|
291
|
+
return aStore.v;
|
|
292
|
+
},
|
|
293
|
+
_v => {
|
|
294
|
+
aCalls++;
|
|
295
|
+
},
|
|
296
|
+
a
|
|
297
|
+
);
|
|
298
|
+
r.createEffect(
|
|
299
|
+
() => {
|
|
300
|
+
r.trackElement(b, 'flag');
|
|
301
|
+
return 1;
|
|
302
|
+
},
|
|
303
|
+
_v => {
|
|
304
|
+
bCalls++;
|
|
305
|
+
},
|
|
306
|
+
b
|
|
307
|
+
);
|
|
308
|
+
await tick();
|
|
309
|
+
expect(aCalls).toBe(1);
|
|
310
|
+
expect(bCalls).toBe(1);
|
|
311
|
+
|
|
312
|
+
// Change the tracked value before notifying so Object.is diff fires.
|
|
313
|
+
aStore.v = 1;
|
|
314
|
+
r.notifyElement(a, 'flag');
|
|
315
|
+
await tick();
|
|
316
|
+
expect(aCalls).toBe(2);
|
|
317
|
+
expect(bCalls).toBe(1);
|
|
318
|
+
|
|
319
|
+
document.body.removeChild(a);
|
|
320
|
+
document.body.removeChild(b);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('caret-variable storage', () => {
|
|
325
|
+
it('reads and writes on same element', () => {
|
|
326
|
+
const el = document.createElement('div');
|
|
327
|
+
r.writeCaret(el, 'x', 42);
|
|
328
|
+
expect(r.readCaret(el, 'x')).toBe(42);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('inherited scope walks up to ancestor with the var defined', () => {
|
|
332
|
+
const parent = document.createElement('div');
|
|
333
|
+
const child = document.createElement('span');
|
|
334
|
+
parent.appendChild(child);
|
|
335
|
+
r.writeCaret(parent, 'theme', 'dark');
|
|
336
|
+
expect(r.readCaret(child, 'theme')).toBe('dark');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('returns undefined for unknown caret vars', () => {
|
|
340
|
+
const el = document.createElement('div');
|
|
341
|
+
expect(r.readCaret(el, 'missing')).toBeUndefined();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('stopElementEffects', () => {
|
|
346
|
+
it('stops all effects owned by an element', async () => {
|
|
347
|
+
const el = document.createElement('div');
|
|
348
|
+
let calls = 0;
|
|
349
|
+
r.createEffect(
|
|
350
|
+
() => {
|
|
351
|
+
r.trackElement(el, 'x');
|
|
352
|
+
return 1;
|
|
353
|
+
},
|
|
354
|
+
_v => {
|
|
355
|
+
calls++;
|
|
356
|
+
},
|
|
357
|
+
el
|
|
358
|
+
);
|
|
359
|
+
await tick();
|
|
360
|
+
expect(calls).toBe(1);
|
|
361
|
+
|
|
362
|
+
r.stopElementEffects(el);
|
|
363
|
+
r.notifyElement(el, 'x');
|
|
364
|
+
await tick();
|
|
365
|
+
await tick();
|
|
366
|
+
expect(calls).toBe(1);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
});
|