@cplieger/reactive 1.2.0 → 1.2.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 +1 -1
- package/package.json +3 -3
- package/src/el.ts +3 -4
- package/src/reconcile-tree.ts +21 -5
- package/src/reconcile.ts +6 -1
- package/src/signal.ts +20 -53
- package/src/store.ts +5 -2
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/reactive",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"exports": {
|
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
"@vitest/coverage-v8": "4.1.9",
|
|
17
17
|
"eslint": "10.5.0",
|
|
18
18
|
"fast-check": "4.8.0",
|
|
19
|
-
"happy-dom": "20.10.
|
|
19
|
+
"happy-dom": "20.10.6",
|
|
20
20
|
"prettier": "3.8.4",
|
|
21
21
|
"typescript": "6.0.3",
|
|
22
|
-
"typescript-eslint": "8.
|
|
22
|
+
"typescript-eslint": "8.62.0",
|
|
23
23
|
"vitest": "4.1.9"
|
|
24
24
|
},
|
|
25
25
|
"description": "Signals + DOM-reconciliation reactive core for TypeScript (zero dependencies)",
|
package/src/el.ts
CHANGED
|
@@ -51,12 +51,11 @@ export function el(
|
|
|
51
51
|
}
|
|
52
52
|
if (k === "className") {
|
|
53
53
|
e.className = v as string;
|
|
54
|
-
} else if (
|
|
54
|
+
} else if (/^on/i.test(k)) {
|
|
55
55
|
(e as unknown as Record<string, unknown>)[k] = v;
|
|
56
56
|
trackHandler(e, k);
|
|
57
|
-
} else if (BOOL_PROPS.has(k)) {
|
|
58
|
-
|
|
59
|
-
} else if (VALUE_PROPS.has(k)) {
|
|
57
|
+
} else if (BOOL_PROPS.has(k) || VALUE_PROPS.has(k)) {
|
|
58
|
+
// Both sets are assigned as DOM properties, not attributes.
|
|
60
59
|
(e as unknown as Record<string, unknown>)[k] = v;
|
|
61
60
|
} else {
|
|
62
61
|
e.setAttribute(k, String(v));
|
package/src/reconcile-tree.ts
CHANGED
|
@@ -24,10 +24,10 @@ export function patch(
|
|
|
24
24
|
if (child == null) {
|
|
25
25
|
continue;
|
|
26
26
|
}
|
|
27
|
-
if (
|
|
28
|
-
newChildren.push(...Array.from((child as Node).childNodes));
|
|
29
|
-
} else if (typeof child === "string") {
|
|
27
|
+
if (typeof child === "string") {
|
|
30
28
|
newChildren.push(document.createTextNode(child));
|
|
29
|
+
} else if (child.nodeType === 11) {
|
|
30
|
+
newChildren.push(...Array.from(child.childNodes));
|
|
31
31
|
} else {
|
|
32
32
|
newChildren.push(child);
|
|
33
33
|
}
|
|
@@ -58,9 +58,15 @@ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
|
|
|
58
58
|
if (matched) {
|
|
59
59
|
oldByKey.delete(newKey);
|
|
60
60
|
} else if (!newKey) {
|
|
61
|
+
// Unkeyed new node: match the next UNKEYED old node by position, advancing
|
|
62
|
+
// past keyed old nodes (which are reserved for key-based matching) and any
|
|
63
|
+
// already-consumed slots. This mirrors the documented contract ("unkeyed
|
|
64
|
+
// nodes match by position, skipping keyed nodes") and React/Preact's
|
|
65
|
+
// keyed+positional reconciliation. Stopping at (and matching) a keyed old
|
|
66
|
+
// node would corrupt a node that key-matching also reuses in the same pass.
|
|
61
67
|
while (oldIdx < oldChildren.length) {
|
|
62
68
|
const oc = oldChildren[oldIdx];
|
|
63
|
-
if (oc
|
|
69
|
+
if (oc !== undefined && !nodeKey(oc)) {
|
|
64
70
|
break;
|
|
65
71
|
}
|
|
66
72
|
oldIdx++;
|
|
@@ -78,7 +84,17 @@ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
|
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
if (!canPatch(matched, newChild)) {
|
|
81
|
-
|
|
87
|
+
// Insert the replacement at the target index i, then drop the mismatched
|
|
88
|
+
// old node — which may NOT be at index i. The positional scan lets an
|
|
89
|
+
// unkeyed new node match a positionally-later unkeyed old node while an
|
|
90
|
+
// unconsumed keyed old node sits ahead of it, so `matched` can be at a
|
|
91
|
+
// position > i. replaceChild would leave newChild at matched's position;
|
|
92
|
+
// a later insert then pushes it past newChildren.length and the trailing
|
|
93
|
+
// removal loop deletes it, dropping the new node and leaving the stale
|
|
94
|
+
// keyed node. The insert and patch branches already target index i.
|
|
95
|
+
const ref = parent.childNodes.item(i);
|
|
96
|
+
parent.insertBefore(newChild, ref);
|
|
97
|
+
parent.removeChild(matched);
|
|
82
98
|
continue;
|
|
83
99
|
}
|
|
84
100
|
|
package/src/reconcile.ts
CHANGED
|
@@ -44,7 +44,12 @@ export function reconcile<T>(
|
|
|
44
44
|
spec.update(el, item);
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
// Skip the move when el is already at its target position: re-inserting a node
|
|
48
|
+
// that is already correctly placed still detaches+reattaches it in the DOM,
|
|
49
|
+
// which blurs a focused input / drops a text selection inside that row.
|
|
50
|
+
if (el.parentNode !== parent || el.nextSibling !== target) {
|
|
51
|
+
parent.insertBefore(el, target);
|
|
52
|
+
}
|
|
48
53
|
target = el;
|
|
49
54
|
}
|
|
50
55
|
|
package/src/signal.ts
CHANGED
|
@@ -8,11 +8,9 @@ const COMPUTED_BRAND = Symbol("computed");
|
|
|
8
8
|
|
|
9
9
|
// --- Bitfield flags ---
|
|
10
10
|
const DIRTY = 1;
|
|
11
|
-
const CHECK = 2;
|
|
12
11
|
const RUNNING = 4;
|
|
13
12
|
const DISPOSED = 8;
|
|
14
13
|
const HAS_ERROR = 16;
|
|
15
|
-
const TRACKING = 32;
|
|
16
14
|
const NOTIFIED = 64;
|
|
17
15
|
|
|
18
16
|
// --- Linked-list Node for source↔target edges ---
|
|
@@ -67,6 +65,10 @@ let batchedEffect: EffectNode | undefined;
|
|
|
67
65
|
let batchIteration = 0;
|
|
68
66
|
let globalVersion = 0;
|
|
69
67
|
|
|
68
|
+
// Max effect-scheduling passes within one endBatch before assuming a cyclic
|
|
69
|
+
// effect-write loop and bailing with "Cycle detected".
|
|
70
|
+
const MAX_BATCH_ITERATIONS = 100;
|
|
71
|
+
|
|
70
72
|
// --- Effect error handler ---
|
|
71
73
|
/** Cleanup function returned by an effect callback, called before re-execution or disposal. */
|
|
72
74
|
export type Cleanup = undefined | (() => void);
|
|
@@ -235,7 +237,7 @@ function needsToRecompute(target: TargetNode): boolean {
|
|
|
235
237
|
const source = node._source;
|
|
236
238
|
if (source._isComputed) {
|
|
237
239
|
const comp = source as unknown as ComputedNode;
|
|
238
|
-
if (comp._flags &
|
|
240
|
+
if (comp._flags & DIRTY) {
|
|
239
241
|
refreshComputed(comp);
|
|
240
242
|
}
|
|
241
243
|
if (comp._flags & HAS_ERROR) {
|
|
@@ -257,42 +259,15 @@ function refreshComputed(comp: ComputedNode): void {
|
|
|
257
259
|
}
|
|
258
260
|
|
|
259
261
|
// Fast-path: nothing changed globally since last check
|
|
260
|
-
if (comp._globalVersion === globalVersion && !(comp._flags &
|
|
262
|
+
if (comp._globalVersion === globalVersion && !(comp._flags & DIRTY)) {
|
|
261
263
|
return;
|
|
262
264
|
}
|
|
263
265
|
comp._globalVersion = globalVersion;
|
|
264
266
|
|
|
265
|
-
// If not dirty but CHECK, verify sources first
|
|
266
|
-
if (!(comp._flags & DIRTY) && comp._flags & CHECK) {
|
|
267
|
-
let sn = comp._sources;
|
|
268
|
-
while (sn !== undefined) {
|
|
269
|
-
const src = sn._source;
|
|
270
|
-
if (src._isComputed) {
|
|
271
|
-
const srcComp = src as unknown as ComputedNode;
|
|
272
|
-
if (srcComp._flags & (DIRTY | CHECK)) {
|
|
273
|
-
refreshComputed(srcComp);
|
|
274
|
-
}
|
|
275
|
-
if (srcComp._flags & HAS_ERROR) {
|
|
276
|
-
comp._flags |= DIRTY;
|
|
277
|
-
break;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
if (src._version !== sn._version) {
|
|
281
|
-
comp._flags |= DIRTY;
|
|
282
|
-
break;
|
|
283
|
-
}
|
|
284
|
-
sn = sn._nextSource;
|
|
285
|
-
}
|
|
286
|
-
if (!(comp._flags & DIRTY)) {
|
|
287
|
-
comp._flags &= ~CHECK;
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
267
|
const prevContext = evalContext;
|
|
293
268
|
evalContext = comp;
|
|
294
269
|
const prevFlags = comp._flags;
|
|
295
|
-
comp._flags = RUNNING
|
|
270
|
+
comp._flags = RUNNING;
|
|
296
271
|
|
|
297
272
|
prepareSources(comp);
|
|
298
273
|
|
|
@@ -304,7 +279,7 @@ function refreshComputed(comp: ComputedNode): void {
|
|
|
304
279
|
// PATH A: fn() threw — cache the error
|
|
305
280
|
fnThrew = true;
|
|
306
281
|
comp._value = err;
|
|
307
|
-
comp._flags = HAS_ERROR
|
|
282
|
+
comp._flags = HAS_ERROR;
|
|
308
283
|
comp._version++;
|
|
309
284
|
// Keep old deps (version=-1 nodes) so computed retries when sources change.
|
|
310
285
|
let sn = comp._sources;
|
|
@@ -318,7 +293,7 @@ function refreshComputed(comp: ComputedNode): void {
|
|
|
318
293
|
evalContext = prevContext;
|
|
319
294
|
cleanupSources(comp);
|
|
320
295
|
if (!fnThrew) {
|
|
321
|
-
comp._flags =
|
|
296
|
+
comp._flags = comp._flags & ~RUNNING;
|
|
322
297
|
}
|
|
323
298
|
}
|
|
324
299
|
|
|
@@ -364,7 +339,7 @@ function endBatch(): void {
|
|
|
364
339
|
let eff: EffectNode | undefined = batchedEffect;
|
|
365
340
|
batchedEffect = undefined;
|
|
366
341
|
batchIteration++;
|
|
367
|
-
if (batchIteration >
|
|
342
|
+
if (batchIteration > MAX_BATCH_ITERATIONS) {
|
|
368
343
|
batchIteration = 0;
|
|
369
344
|
throw new Error("Cycle detected");
|
|
370
345
|
}
|
|
@@ -391,8 +366,7 @@ function endBatch(): void {
|
|
|
391
366
|
}
|
|
392
367
|
}
|
|
393
368
|
|
|
394
|
-
function
|
|
395
|
-
// Run cleanup first (untracked)
|
|
369
|
+
function runCleanup(eff: EffectNode): void {
|
|
396
370
|
if (eff._cleanup) {
|
|
397
371
|
const c = eff._cleanup;
|
|
398
372
|
eff._cleanup = undefined;
|
|
@@ -406,6 +380,11 @@ function runEffect(eff: EffectNode): void {
|
|
|
406
380
|
evalContext = prevContext;
|
|
407
381
|
}
|
|
408
382
|
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function runEffect(eff: EffectNode): void {
|
|
386
|
+
// Run cleanup first (untracked)
|
|
387
|
+
runCleanup(eff);
|
|
409
388
|
|
|
410
389
|
// Track dependencies
|
|
411
390
|
const prevContext = evalContext;
|
|
@@ -426,19 +405,7 @@ function runEffect(eff: EffectNode): void {
|
|
|
426
405
|
|
|
427
406
|
function disposeEffect(eff: EffectNode): void {
|
|
428
407
|
// Run cleanup (untracked)
|
|
429
|
-
|
|
430
|
-
const c = eff._cleanup;
|
|
431
|
-
eff._cleanup = undefined;
|
|
432
|
-
const prevContext = evalContext;
|
|
433
|
-
evalContext = undefined;
|
|
434
|
-
try {
|
|
435
|
-
c();
|
|
436
|
-
} catch (e) {
|
|
437
|
-
safeCallHandler(e);
|
|
438
|
-
} finally {
|
|
439
|
-
evalContext = prevContext;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
408
|
+
runCleanup(eff);
|
|
442
409
|
|
|
443
410
|
// Unsubscribe from all sources
|
|
444
411
|
let node = eff._sources;
|
|
@@ -533,7 +500,7 @@ export function computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySi
|
|
|
533
500
|
_targets: undefined,
|
|
534
501
|
_isComputed: true,
|
|
535
502
|
// TargetNode fields
|
|
536
|
-
_flags: DIRTY
|
|
503
|
+
_flags: DIRTY,
|
|
537
504
|
_sources: undefined,
|
|
538
505
|
// ComputedNode-specific fields
|
|
539
506
|
_fn: fn,
|
|
@@ -550,7 +517,7 @@ export function computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySi
|
|
|
550
517
|
throw new Error("Cycle detected");
|
|
551
518
|
}
|
|
552
519
|
// Refresh before tracking (pull-based glitch-free)
|
|
553
|
-
if (node._flags &
|
|
520
|
+
if (node._flags & DIRTY) {
|
|
554
521
|
refreshComputed(node);
|
|
555
522
|
}
|
|
556
523
|
const dep = addDependency(node);
|
|
@@ -567,7 +534,7 @@ export function computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySi
|
|
|
567
534
|
},
|
|
568
535
|
peek(): T {
|
|
569
536
|
// F6: refresh WITHOUT tracking
|
|
570
|
-
if (node._flags &
|
|
537
|
+
if (node._flags & DIRTY) {
|
|
571
538
|
const prevContext = evalContext;
|
|
572
539
|
evalContext = undefined;
|
|
573
540
|
try {
|
package/src/store.ts
CHANGED
|
@@ -61,8 +61,11 @@ export function createStore<M>(): Store<M> {
|
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
// A derived key: an effect that writes `outputKey` from `fn`.
|
|
65
|
-
// `outputKey`
|
|
64
|
+
// A derived key: an effect that writes `outputKey` from `fn`. A `fn` that reads
|
|
65
|
+
// `outputKey` and yields a new value each run trips the engine's batch-iteration
|
|
66
|
+
// guard after ~100 re-runs, surfacing Error("Cycle detected") through the effect
|
|
67
|
+
// error handler (effects isolate errors, so it is NOT rethrown to the caller); a
|
|
68
|
+
// self-read that returns a stable value settles via Object.is dedup without looping.
|
|
66
69
|
function computed<K extends keyof M & string>(outputKey: K, fn: () => M[K]): () => void {
|
|
67
70
|
return effect(() => {
|
|
68
71
|
set(outputKey, fn());
|