@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.
@@ -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
+ });