@pyreon/runtime-dom 0.15.0 → 0.18.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/lib/analysis/index.js.html +1 -1
- package/lib/analysis/keep-alive-entry.js.html +1 -1
- package/lib/index.js +92 -49
- package/lib/keep-alive-entry.js +80 -41
- package/package.json +6 -6
- package/src/keep-alive.ts +10 -3
- package/src/nodes.ts +86 -39
- package/src/props.ts +27 -1
- package/src/template.ts +2 -0
- package/src/tests/fanout-repro.test.tsx +219 -0
- package/src/tests/mount.test.ts +1 -1
- package/src/tests/transition.test.ts +10 -2
- package/src/transition-group.ts +16 -6
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"8df5ffbc-1","name":"delegate.ts"},{"uid":"8df5ffbc-3","name":"hydration-debug.ts"},{"uid":"8df5ffbc-5","name":"devtools.ts"},{"uid":"8df5ffbc-7","name":"nodes.ts"},{"uid":"8df5ffbc-9","name":"props.ts"},{"uid":"8df5ffbc-11","name":"mount.ts"},{"uid":"8df5ffbc-13","name":"hydrate.ts"},{"uid":"8df5ffbc-15","name":"keep-alive.ts"},{"uid":"8df5ffbc-17","name":"template.ts"},{"uid":"8df5ffbc-19","name":"transition.ts"},{"uid":"8df5ffbc-21","name":"transition-group.ts"},{"uid":"8df5ffbc-23","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"8df5ffbc-1":{"renderedLength":2090,"gzipLength":1029,"brotliLength":0,"metaUid":"8df5ffbc-0"},"8df5ffbc-3":{"renderedLength":1395,"gzipLength":718,"brotliLength":0,"metaUid":"8df5ffbc-2"},"8df5ffbc-5":{"renderedLength":7009,"gzipLength":2149,"brotliLength":0,"metaUid":"8df5ffbc-4"},"8df5ffbc-7":{"renderedLength":17556,"gzipLength":4660,"brotliLength":0,"metaUid":"8df5ffbc-6"},"8df5ffbc-9":{"renderedLength":8288,"gzipLength":3134,"brotliLength":0,"metaUid":"8df5ffbc-8"},"8df5ffbc-11":{"renderedLength":12232,"gzipLength":3882,"brotliLength":0,"metaUid":"8df5ffbc-10"},"8df5ffbc-13":{"renderedLength":8312,"gzipLength":2472,"brotliLength":0,"metaUid":"8df5ffbc-12"},"8df5ffbc-15":{"renderedLength":1518,"gzipLength":724,"brotliLength":0,"metaUid":"8df5ffbc-14"},"8df5ffbc-17":{"renderedLength":5942,"gzipLength":2283,"brotliLength":0,"metaUid":"8df5ffbc-16"},"8df5ffbc-19":{"renderedLength":4929,"gzipLength":1410,"brotliLength":0,"metaUid":"8df5ffbc-18"},"8df5ffbc-21":{"renderedLength":8002,"gzipLength":2092,"brotliLength":0,"metaUid":"8df5ffbc-20"},"8df5ffbc-23":{"renderedLength":985,"gzipLength":549,"brotliLength":0,"metaUid":"8df5ffbc-22"}},"nodeMetas":{"8df5ffbc-0":{"id":"/src/delegate.ts","moduleParts":{"index.js":"8df5ffbc-1"},"imported":[{"uid":"8df5ffbc-24"}],"importedBy":[{"uid":"8df5ffbc-22"},{"uid":"8df5ffbc-12"},{"uid":"8df5ffbc-8"}]},"8df5ffbc-2":{"id":"/src/hydration-debug.ts","moduleParts":{"index.js":"8df5ffbc-3"},"imported":[],"importedBy":[{"uid":"8df5ffbc-22"},{"uid":"8df5ffbc-12"}]},"8df5ffbc-4":{"id":"/src/devtools.ts","moduleParts":{"index.js":"8df5ffbc-5"},"imported":[],"importedBy":[{"uid":"8df5ffbc-22"},{"uid":"8df5ffbc-10"}]},"8df5ffbc-6":{"id":"/src/nodes.ts","moduleParts":{"index.js":"8df5ffbc-7"},"imported":[{"uid":"8df5ffbc-25"},{"uid":"8df5ffbc-24"}],"importedBy":[{"uid":"8df5ffbc-12"},{"uid":"8df5ffbc-10"}]},"8df5ffbc-8":{"id":"/src/props.ts","moduleParts":{"index.js":"8df5ffbc-9"},"imported":[{"uid":"8df5ffbc-25"},{"uid":"8df5ffbc-24"},{"uid":"8df5ffbc-0"}],"importedBy":[{"uid":"8df5ffbc-22"},{"uid":"8df5ffbc-12"},{"uid":"8df5ffbc-10"}]},"8df5ffbc-10":{"id":"/src/mount.ts","moduleParts":{"index.js":"8df5ffbc-11"},"imported":[{"uid":"8df5ffbc-25"},{"uid":"8df5ffbc-24"},{"uid":"8df5ffbc-4"},{"uid":"8df5ffbc-6"},{"uid":"8df5ffbc-8"}],"importedBy":[{"uid":"8df5ffbc-22"},{"uid":"8df5ffbc-12"},{"uid":"8df5ffbc-14"},{"uid":"8df5ffbc-16"},{"uid":"8df5ffbc-20"}]},"8df5ffbc-12":{"id":"/src/hydrate.ts","moduleParts":{"index.js":"8df5ffbc-13"},"imported":[{"uid":"8df5ffbc-25"},{"uid":"8df5ffbc-24"},{"uid":"8df5ffbc-0"},{"uid":"8df5ffbc-2"},{"uid":"8df5ffbc-10"},{"uid":"8df5ffbc-6"},{"uid":"8df5ffbc-8"}],"importedBy":[{"uid":"8df5ffbc-22"}]},"8df5ffbc-14":{"id":"/src/keep-alive.ts","moduleParts":{"index.js":"8df5ffbc-15"},"imported":[{"uid":"8df5ffbc-25"},{"uid":"8df5ffbc-24"},{"uid":"8df5ffbc-10"}],"importedBy":[{"uid":"8df5ffbc-22"}]},"8df5ffbc-16":{"id":"/src/template.ts","moduleParts":{"index.js":"8df5ffbc-17"},"imported":[{"uid":"8df5ffbc-24"},{"uid":"8df5ffbc-10"}],"importedBy":[{"uid":"8df5ffbc-22"}]},"8df5ffbc-18":{"id":"/src/transition.ts","moduleParts":{"index.js":"8df5ffbc-19"},"imported":[{"uid":"8df5ffbc-25"},{"uid":"8df5ffbc-24"}],"importedBy":[{"uid":"8df5ffbc-22"}]},"8df5ffbc-20":{"id":"/src/transition-group.ts","moduleParts":{"index.js":"8df5ffbc-21"},"imported":[{"uid":"8df5ffbc-25"},{"uid":"8df5ffbc-24"},{"uid":"8df5ffbc-10"}],"importedBy":[{"uid":"8df5ffbc-22"}]},"8df5ffbc-22":{"id":"/src/index.ts","moduleParts":{"index.js":"8df5ffbc-23"},"imported":[{"uid":"8df5ffbc-0"},{"uid":"8df5ffbc-12"},{"uid":"8df5ffbc-2"},{"uid":"8df5ffbc-14"},{"uid":"8df5ffbc-10"},{"uid":"8df5ffbc-8"},{"uid":"8df5ffbc-16"},{"uid":"8df5ffbc-18"},{"uid":"8df5ffbc-20"},{"uid":"8df5ffbc-4"}],"importedBy":[],"isEntry":true},"8df5ffbc-24":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"8df5ffbc-0"},{"uid":"8df5ffbc-12"},{"uid":"8df5ffbc-14"},{"uid":"8df5ffbc-10"},{"uid":"8df5ffbc-8"},{"uid":"8df5ffbc-16"},{"uid":"8df5ffbc-18"},{"uid":"8df5ffbc-20"},{"uid":"8df5ffbc-6"}]},"8df5ffbc-25":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"8df5ffbc-12"},{"uid":"8df5ffbc-14"},{"uid":"8df5ffbc-10"},{"uid":"8df5ffbc-8"},{"uid":"8df5ffbc-18"},{"uid":"8df5ffbc-20"},{"uid":"8df5ffbc-6"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"keep-alive-entry.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"keep-alive-entry.js","children":[{"name":"src","children":[{"uid":"56ea40e5-1","name":"devtools.ts"},{"uid":"56ea40e5-3","name":"nodes.ts"},{"uid":"56ea40e5-5","name":"delegate.ts"},{"uid":"56ea40e5-7","name":"props.ts"},{"uid":"56ea40e5-9","name":"mount.ts"},{"uid":"56ea40e5-11","name":"keep-alive.ts"}]}]}],"isRoot":true},"nodeParts":{"56ea40e5-1":{"renderedLength":759,"gzipLength":340,"brotliLength":0,"metaUid":"56ea40e5-0"},"56ea40e5-3":{"renderedLength":17556,"gzipLength":4658,"brotliLength":0,"metaUid":"56ea40e5-2"},"56ea40e5-5":{"renderedLength":790,"gzipLength":436,"brotliLength":0,"metaUid":"56ea40e5-4"},"56ea40e5-7":{"renderedLength":7818,"gzipLength":2974,"brotliLength":0,"metaUid":"56ea40e5-6"},"56ea40e5-9":{"renderedLength":12162,"gzipLength":3873,"brotliLength":0,"metaUid":"56ea40e5-8"},"56ea40e5-11":{"renderedLength":1518,"gzipLength":724,"brotliLength":0,"metaUid":"56ea40e5-10"}},"nodeMetas":{"56ea40e5-0":{"id":"/src/devtools.ts","moduleParts":{"keep-alive-entry.js":"56ea40e5-1"},"imported":[],"importedBy":[{"uid":"56ea40e5-8"}]},"56ea40e5-2":{"id":"/src/nodes.ts","moduleParts":{"keep-alive-entry.js":"56ea40e5-3"},"imported":[{"uid":"56ea40e5-12"},{"uid":"56ea40e5-13"}],"importedBy":[{"uid":"56ea40e5-8"}]},"56ea40e5-4":{"id":"/src/delegate.ts","moduleParts":{"keep-alive-entry.js":"56ea40e5-5"},"imported":[{"uid":"56ea40e5-13"}],"importedBy":[{"uid":"56ea40e5-6"}]},"56ea40e5-6":{"id":"/src/props.ts","moduleParts":{"keep-alive-entry.js":"56ea40e5-7"},"imported":[{"uid":"56ea40e5-12"},{"uid":"56ea40e5-13"},{"uid":"56ea40e5-4"}],"importedBy":[{"uid":"56ea40e5-8"}]},"56ea40e5-8":{"id":"/src/mount.ts","moduleParts":{"keep-alive-entry.js":"56ea40e5-9"},"imported":[{"uid":"56ea40e5-12"},{"uid":"56ea40e5-13"},{"uid":"56ea40e5-0"},{"uid":"56ea40e5-2"},{"uid":"56ea40e5-6"}],"importedBy":[{"uid":"56ea40e5-10"}]},"56ea40e5-10":{"id":"/src/keep-alive.ts","moduleParts":{"keep-alive-entry.js":"56ea40e5-11"},"imported":[{"uid":"56ea40e5-12"},{"uid":"56ea40e5-13"},{"uid":"56ea40e5-8"}],"importedBy":[],"isEntry":true},"56ea40e5-12":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"56ea40e5-10"},{"uid":"56ea40e5-8"},{"uid":"56ea40e5-2"},{"uid":"56ea40e5-6"}]},"56ea40e5-13":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"56ea40e5-10"},{"uid":"56ea40e5-8"},{"uid":"56ea40e5-2"},{"uid":"56ea40e5-6"},{"uid":"56ea40e5-4"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -341,7 +341,7 @@ function installDevTools() {
|
|
|
341
341
|
//#endregion
|
|
342
342
|
//#region src/nodes.ts
|
|
343
343
|
const __DEV__$5 = process.env.NODE_ENV !== "production";
|
|
344
|
-
const _countSink$
|
|
344
|
+
const _countSink$4 = globalThis;
|
|
345
345
|
/**
|
|
346
346
|
* Move all nodes strictly between `start` and `end` into a throwaway
|
|
347
347
|
* DocumentFragment, detaching them from the live DOM in O(n) top-level moves.
|
|
@@ -363,6 +363,10 @@ function clearBetween(start, end) {
|
|
|
363
363
|
cur = next;
|
|
364
364
|
}
|
|
365
365
|
}
|
|
366
|
+
/** Emit `runtime.cleanup` once per registered mount cleanup that actually runs. */
|
|
367
|
+
function _emitCleanup() {
|
|
368
|
+
if (__DEV__$5) _countSink$4.__pyreon_count__?.("runtime.cleanup");
|
|
369
|
+
}
|
|
366
370
|
/**
|
|
367
371
|
* Mount a reactive node whose content changes over time.
|
|
368
372
|
*
|
|
@@ -370,24 +374,34 @@ function clearBetween(start, end) {
|
|
|
370
374
|
* On each change: old nodes are removed, new ones inserted before the anchor.
|
|
371
375
|
*/
|
|
372
376
|
function mountReactive(accessor, parent, anchor, mount) {
|
|
377
|
+
if (__DEV__$5) _countSink$4.__pyreon_count__?.("runtime.mountReactive");
|
|
373
378
|
const marker = document.createComment("pyreon");
|
|
374
379
|
parent.insertBefore(marker, anchor);
|
|
375
380
|
const contextSnapshot = captureContextStack();
|
|
376
381
|
let currentCleanup = () => {};
|
|
382
|
+
let hasCleanup = false;
|
|
377
383
|
let generation = 0;
|
|
378
384
|
const e = effect(() => {
|
|
379
385
|
const myGen = ++generation;
|
|
386
|
+
if (hasCleanup) _emitCleanup();
|
|
380
387
|
runUntracked(() => currentCleanup());
|
|
381
388
|
currentCleanup = () => {};
|
|
389
|
+
hasCleanup = false;
|
|
382
390
|
const value = accessor();
|
|
383
391
|
if (value != null && value !== false) {
|
|
384
392
|
const cleanup = runUntracked(() => restoreContextStack(contextSnapshot, () => mount(value, parent, marker)));
|
|
385
|
-
if (myGen === generation)
|
|
386
|
-
|
|
393
|
+
if (myGen === generation) {
|
|
394
|
+
currentCleanup = cleanup;
|
|
395
|
+
hasCleanup = true;
|
|
396
|
+
} else {
|
|
397
|
+
_emitCleanup();
|
|
398
|
+
cleanup();
|
|
399
|
+
}
|
|
387
400
|
}
|
|
388
401
|
});
|
|
389
402
|
return () => {
|
|
390
403
|
e.dispose();
|
|
404
|
+
if (hasCleanup) _emitCleanup();
|
|
391
405
|
currentCleanup();
|
|
392
406
|
marker.parentNode?.removeChild(marker);
|
|
393
407
|
};
|
|
@@ -424,7 +438,7 @@ function computeKeyedLis(lis, n, newKeyOrder, curPos) {
|
|
|
424
438
|
if (lo > 0) pred[i] = tailIdx[lo - 1];
|
|
425
439
|
if (lo === lisLen) lisLen++;
|
|
426
440
|
}
|
|
427
|
-
if (__DEV__$5 && ops > 0) _countSink$
|
|
441
|
+
if (__DEV__$5 && ops > 0) _countSink$4.__pyreon_count__?.("runtime.mountFor.lisOps", ops);
|
|
428
442
|
return lisLen;
|
|
429
443
|
}
|
|
430
444
|
function markStayingEntries(lis, lisLen) {
|
|
@@ -487,6 +501,7 @@ function mountKeyedList(accessor, parent, listAnchor, mountVNode) {
|
|
|
487
501
|
const removeStaleEntries = (newKeySet) => {
|
|
488
502
|
for (const [key, entry] of cache) {
|
|
489
503
|
if (newKeySet.has(key)) continue;
|
|
504
|
+
_emitCleanup();
|
|
490
505
|
entry.cleanup();
|
|
491
506
|
entry.anchor.parentNode?.removeChild(entry.anchor);
|
|
492
507
|
cache.delete(key);
|
|
@@ -511,28 +526,34 @@ function mountKeyedList(accessor, parent, listAnchor, mountVNode) {
|
|
|
511
526
|
const e = effect(() => {
|
|
512
527
|
const newList = accessor();
|
|
513
528
|
const n = newList.length;
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
529
|
+
runUntracked(() => {
|
|
530
|
+
if (n === 0 && cache.size > 0) {
|
|
531
|
+
for (const entry of cache.values()) {
|
|
532
|
+
_emitCleanup();
|
|
533
|
+
entry.cleanup();
|
|
534
|
+
}
|
|
535
|
+
cache.clear();
|
|
536
|
+
curPos.clear();
|
|
537
|
+
currentKeyOrder = [];
|
|
538
|
+
clearBetween(startMarker, tailMarker);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const { newKeyOrder, newKeySet } = collectKeyOrder(newList);
|
|
542
|
+
removeStaleEntries(newKeySet);
|
|
543
|
+
mountNewEntries(newList);
|
|
544
|
+
if (currentKeyOrder.length > 0 && n > 0) lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, parent, tailMarker);
|
|
517
545
|
curPos.clear();
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
mountNewEntries(newList);
|
|
525
|
-
if (currentKeyOrder.length > 0 && n > 0) lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, parent, tailMarker);
|
|
526
|
-
curPos.clear();
|
|
527
|
-
for (let i = 0; i < newKeyOrder.length; i++) {
|
|
528
|
-
const k = newKeyOrder[i];
|
|
529
|
-
if (k !== void 0) curPos.set(k, i);
|
|
530
|
-
}
|
|
531
|
-
currentKeyOrder = newKeyOrder;
|
|
546
|
+
for (let i = 0; i < newKeyOrder.length; i++) {
|
|
547
|
+
const k = newKeyOrder[i];
|
|
548
|
+
if (k !== void 0) curPos.set(k, i);
|
|
549
|
+
}
|
|
550
|
+
currentKeyOrder = newKeyOrder;
|
|
551
|
+
});
|
|
532
552
|
});
|
|
533
553
|
return () => {
|
|
534
554
|
e.dispose();
|
|
535
555
|
for (const entry of cache.values()) {
|
|
556
|
+
_emitCleanup();
|
|
536
557
|
entry.cleanup();
|
|
537
558
|
entry.anchor.parentNode?.removeChild(entry.anchor);
|
|
538
559
|
}
|
|
@@ -591,7 +612,7 @@ function computeForLis(lis, n, newKeys, cache) {
|
|
|
591
612
|
tailIdx[lo] = i;
|
|
592
613
|
if (lo > 0) pred[i] = tailIdx[lo - 1];
|
|
593
614
|
}
|
|
594
|
-
if (__DEV__$5 && ops > 0) _countSink$
|
|
615
|
+
if (__DEV__$5 && ops > 0) _countSink$4.__pyreon_count__?.("runtime.mountFor.lisOps", ops);
|
|
595
616
|
return lisLen;
|
|
596
617
|
}
|
|
597
618
|
function applyForMoves(n, newKeys, stay, cache, liveParent, tailMarker) {
|
|
@@ -709,7 +730,10 @@ function mountFor(source, getKey, renderItem, parent, anchor, mountChild) {
|
|
|
709
730
|
};
|
|
710
731
|
const handleReplaceAll = (items, n, newKeys, liveParent) => {
|
|
711
732
|
if (cleanupCount > 0) {
|
|
712
|
-
for (const entry of cache.values()) if (entry.cleanup)
|
|
733
|
+
for (const entry of cache.values()) if (entry.cleanup) {
|
|
734
|
+
_emitCleanup();
|
|
735
|
+
entry.cleanup();
|
|
736
|
+
}
|
|
713
737
|
}
|
|
714
738
|
cache = /* @__PURE__ */ new Map();
|
|
715
739
|
cleanupCount = 0;
|
|
@@ -734,6 +758,7 @@ function mountFor(source, getKey, renderItem, parent, anchor, mountChild) {
|
|
|
734
758
|
for (const [key, entry] of cache) {
|
|
735
759
|
if (newKeySet.has(key)) continue;
|
|
736
760
|
if (entry.cleanup) {
|
|
761
|
+
_emitCleanup();
|
|
737
762
|
entry.cleanup();
|
|
738
763
|
cleanupCount--;
|
|
739
764
|
}
|
|
@@ -753,7 +778,10 @@ function mountFor(source, getKey, renderItem, parent, anchor, mountChild) {
|
|
|
753
778
|
const handleFastClear = (liveParent) => {
|
|
754
779
|
if (cache.size === 0) return;
|
|
755
780
|
if (cleanupCount > 0) {
|
|
756
|
-
for (const entry of cache.values()) if (entry.cleanup)
|
|
781
|
+
for (const entry of cache.values()) if (entry.cleanup) {
|
|
782
|
+
_emitCleanup();
|
|
783
|
+
entry.cleanup();
|
|
784
|
+
}
|
|
757
785
|
}
|
|
758
786
|
const pp = liveParent.parentNode;
|
|
759
787
|
if (pp && liveParent.firstChild === startMarker && liveParent.lastChild === tailMarker) {
|
|
@@ -791,25 +819,30 @@ function mountFor(source, getKey, renderItem, parent, anchor, mountChild) {
|
|
|
791
819
|
if (!liveParent) return;
|
|
792
820
|
const items = source();
|
|
793
821
|
const n = items.length;
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
822
|
+
runUntracked(() => {
|
|
823
|
+
if (n === 0) {
|
|
824
|
+
handleFastClear(liveParent);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (currentKeys.length === 0) {
|
|
828
|
+
handleFreshRender(items, n, liveParent);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const newKeys = collectNewKeys(items, n);
|
|
832
|
+
if (!hasAnyKeptKey(n, newKeys)) {
|
|
833
|
+
handleReplaceAll(items, n, newKeys, liveParent);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
handleIncrementalUpdate(items, n, newKeys, liveParent);
|
|
837
|
+
});
|
|
808
838
|
});
|
|
809
839
|
return () => {
|
|
810
840
|
e.dispose();
|
|
811
841
|
for (const entry of cache.values()) {
|
|
812
|
-
if (cleanupCount > 0 && entry.cleanup)
|
|
842
|
+
if (cleanupCount > 0 && entry.cleanup) {
|
|
843
|
+
_emitCleanup();
|
|
844
|
+
entry.cleanup();
|
|
845
|
+
}
|
|
813
846
|
entry.anchor.parentNode?.removeChild(entry.anchor);
|
|
814
847
|
}
|
|
815
848
|
cache = /* @__PURE__ */ new Map();
|
|
@@ -873,6 +906,7 @@ function moveEntryBefore(parent, startNode, before) {
|
|
|
873
906
|
//#endregion
|
|
874
907
|
//#region src/props.ts
|
|
875
908
|
const __DEV__$4 = process.env.NODE_ENV !== "production";
|
|
909
|
+
const _countSink$3 = globalThis;
|
|
876
910
|
let _customSanitizer = null;
|
|
877
911
|
/**
|
|
878
912
|
* Set a custom HTML sanitizer used by `innerHTML` and `sanitizeHtml()`.
|
|
@@ -1015,7 +1049,10 @@ function applyProps(el, props) {
|
|
|
1015
1049
|
let cleanups = null;
|
|
1016
1050
|
for (const key in props) {
|
|
1017
1051
|
if (key === "key" || key === "ref" || key === "children") continue;
|
|
1018
|
-
const
|
|
1052
|
+
const descriptor = Object.getOwnPropertyDescriptor(props, key);
|
|
1053
|
+
let c;
|
|
1054
|
+
if (descriptor?.get) c = renderEffect(() => applyStaticProp(el, key, props[key]));
|
|
1055
|
+
else c = applyProp(el, key, props[key]);
|
|
1019
1056
|
if (c) if (!first) first = c;
|
|
1020
1057
|
else if (!cleanups) cleanups = [first, c];
|
|
1021
1058
|
else cleanups.push(c);
|
|
@@ -1036,6 +1073,7 @@ function applyProps(el, props) {
|
|
|
1036
1073
|
* Bind an event handler (onClick → "click") with batching + delegation support.
|
|
1037
1074
|
*/
|
|
1038
1075
|
function applyEventProp(el, key, value) {
|
|
1076
|
+
if (__DEV__$4) _countSink$3.__pyreon_count__?.("runtime.applyEvent");
|
|
1039
1077
|
if (typeof value !== "function") {
|
|
1040
1078
|
if (__DEV__$4 && value != null) console.warn(`[Pyreon] Event handler "${key}" received a non-function value (${typeof value}). Expected a function. Did you mean ${key}={() => ...}?`);
|
|
1041
1079
|
return null;
|
|
@@ -1082,6 +1120,7 @@ function applyStaticProp(el, key, value) {
|
|
|
1082
1120
|
setStaticProp(el, key, value);
|
|
1083
1121
|
}
|
|
1084
1122
|
function applyProp(el, key, value) {
|
|
1123
|
+
if (__DEV__$4) _countSink$3.__pyreon_count__?.("runtime.applyProp");
|
|
1085
1124
|
if (EVENT_RE.test(key)) return applyEventProp(el, key, value);
|
|
1086
1125
|
if (typeof value === "function") return renderEffect(() => applyStaticProp(el, key, value()));
|
|
1087
1126
|
applyStaticProp(el, key, value);
|
|
@@ -1829,7 +1868,7 @@ function KeepAlive(props) {
|
|
|
1829
1868
|
const e = effect(() => {
|
|
1830
1869
|
const isActive = props.active?.() ?? true;
|
|
1831
1870
|
if (!childMounted) {
|
|
1832
|
-
childCleanup = mountChild(props.children ?? null, container, null);
|
|
1871
|
+
childCleanup = runUntracked(() => mountChild(props.children ?? null, container, null));
|
|
1833
1872
|
childMounted = true;
|
|
1834
1873
|
}
|
|
1835
1874
|
container.style.display = isActive ? "" : "none";
|
|
@@ -1905,6 +1944,7 @@ function createTemplate(html, bind) {
|
|
|
1905
1944
|
* @param node - The Text node to update
|
|
1906
1945
|
*/
|
|
1907
1946
|
function _bindText(source, node) {
|
|
1947
|
+
if (__DEV__$2) _countSink$1.__pyreon_count__?.("runtime.bindText");
|
|
1908
1948
|
if (source.direct) {
|
|
1909
1949
|
const textUpdate = () => {
|
|
1910
1950
|
const v = source._v;
|
|
@@ -1938,6 +1978,7 @@ function _bindText(source, node) {
|
|
|
1938
1978
|
* @param updater - Function that reads `source._v` and applies the DOM update
|
|
1939
1979
|
*/
|
|
1940
1980
|
function _bindDirect(source, updater) {
|
|
1981
|
+
if (__DEV__$2) _countSink$1.__pyreon_count__?.("runtime.bindDirect");
|
|
1941
1982
|
if (source.direct) {
|
|
1942
1983
|
updater(source._v);
|
|
1943
1984
|
return source.direct(() => updater(source._v));
|
|
@@ -2312,17 +2353,19 @@ function TransitionGroup(props) {
|
|
|
2312
2353
|
const key = props.keyFn(item, i);
|
|
2313
2354
|
if (entries.has(key)) continue;
|
|
2314
2355
|
const itemRef = createRef();
|
|
2315
|
-
const rawVNode = runUntracked(() => props.render(item, i));
|
|
2316
2356
|
const entry = {
|
|
2317
2357
|
key,
|
|
2318
2358
|
ref: itemRef,
|
|
2319
|
-
cleanup:
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
...rawVNode
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2359
|
+
cleanup: runUntracked(() => {
|
|
2360
|
+
const rawVNode = props.render(item, i);
|
|
2361
|
+
return mountChild(typeof rawVNode.type === "string" ? {
|
|
2362
|
+
...rawVNode,
|
|
2363
|
+
props: {
|
|
2364
|
+
...rawVNode.props,
|
|
2365
|
+
ref: itemRef
|
|
2366
|
+
}
|
|
2367
|
+
} : rawVNode, container, null);
|
|
2368
|
+
}),
|
|
2326
2369
|
leaving: false,
|
|
2327
2370
|
cancelTransition: null
|
|
2328
2371
|
};
|
package/lib/keep-alive-entry.js
CHANGED
|
@@ -34,7 +34,7 @@ function unregisterComponent(id) {
|
|
|
34
34
|
//#endregion
|
|
35
35
|
//#region src/nodes.ts
|
|
36
36
|
const __DEV__$2 = process.env.NODE_ENV !== "production";
|
|
37
|
-
const _countSink$
|
|
37
|
+
const _countSink$2 = globalThis;
|
|
38
38
|
/**
|
|
39
39
|
* Move all nodes strictly between `start` and `end` into a throwaway
|
|
40
40
|
* DocumentFragment, detaching them from the live DOM in O(n) top-level moves.
|
|
@@ -56,6 +56,10 @@ function clearBetween(start, end) {
|
|
|
56
56
|
cur = next;
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
/** Emit `runtime.cleanup` once per registered mount cleanup that actually runs. */
|
|
60
|
+
function _emitCleanup() {
|
|
61
|
+
if (__DEV__$2) _countSink$2.__pyreon_count__?.("runtime.cleanup");
|
|
62
|
+
}
|
|
59
63
|
/**
|
|
60
64
|
* Mount a reactive node whose content changes over time.
|
|
61
65
|
*
|
|
@@ -63,24 +67,34 @@ function clearBetween(start, end) {
|
|
|
63
67
|
* On each change: old nodes are removed, new ones inserted before the anchor.
|
|
64
68
|
*/
|
|
65
69
|
function mountReactive(accessor, parent, anchor, mount) {
|
|
70
|
+
if (__DEV__$2) _countSink$2.__pyreon_count__?.("runtime.mountReactive");
|
|
66
71
|
const marker = document.createComment("pyreon");
|
|
67
72
|
parent.insertBefore(marker, anchor);
|
|
68
73
|
const contextSnapshot = captureContextStack();
|
|
69
74
|
let currentCleanup = () => {};
|
|
75
|
+
let hasCleanup = false;
|
|
70
76
|
let generation = 0;
|
|
71
77
|
const e = effect(() => {
|
|
72
78
|
const myGen = ++generation;
|
|
79
|
+
if (hasCleanup) _emitCleanup();
|
|
73
80
|
runUntracked(() => currentCleanup());
|
|
74
81
|
currentCleanup = () => {};
|
|
82
|
+
hasCleanup = false;
|
|
75
83
|
const value = accessor();
|
|
76
84
|
if (value != null && value !== false) {
|
|
77
85
|
const cleanup = runUntracked(() => restoreContextStack(contextSnapshot, () => mount(value, parent, marker)));
|
|
78
|
-
if (myGen === generation)
|
|
79
|
-
|
|
86
|
+
if (myGen === generation) {
|
|
87
|
+
currentCleanup = cleanup;
|
|
88
|
+
hasCleanup = true;
|
|
89
|
+
} else {
|
|
90
|
+
_emitCleanup();
|
|
91
|
+
cleanup();
|
|
92
|
+
}
|
|
80
93
|
}
|
|
81
94
|
});
|
|
82
95
|
return () => {
|
|
83
96
|
e.dispose();
|
|
97
|
+
if (hasCleanup) _emitCleanup();
|
|
84
98
|
currentCleanup();
|
|
85
99
|
marker.parentNode?.removeChild(marker);
|
|
86
100
|
};
|
|
@@ -117,7 +131,7 @@ function computeKeyedLis(lis, n, newKeyOrder, curPos) {
|
|
|
117
131
|
if (lo > 0) pred[i] = tailIdx[lo - 1];
|
|
118
132
|
if (lo === lisLen) lisLen++;
|
|
119
133
|
}
|
|
120
|
-
if (__DEV__$2 && ops > 0) _countSink$
|
|
134
|
+
if (__DEV__$2 && ops > 0) _countSink$2.__pyreon_count__?.("runtime.mountFor.lisOps", ops);
|
|
121
135
|
return lisLen;
|
|
122
136
|
}
|
|
123
137
|
function markStayingEntries(lis, lisLen) {
|
|
@@ -180,6 +194,7 @@ function mountKeyedList(accessor, parent, listAnchor, mountVNode) {
|
|
|
180
194
|
const removeStaleEntries = (newKeySet) => {
|
|
181
195
|
for (const [key, entry] of cache) {
|
|
182
196
|
if (newKeySet.has(key)) continue;
|
|
197
|
+
_emitCleanup();
|
|
183
198
|
entry.cleanup();
|
|
184
199
|
entry.anchor.parentNode?.removeChild(entry.anchor);
|
|
185
200
|
cache.delete(key);
|
|
@@ -204,28 +219,34 @@ function mountKeyedList(accessor, parent, listAnchor, mountVNode) {
|
|
|
204
219
|
const e = effect(() => {
|
|
205
220
|
const newList = accessor();
|
|
206
221
|
const n = newList.length;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
222
|
+
runUntracked(() => {
|
|
223
|
+
if (n === 0 && cache.size > 0) {
|
|
224
|
+
for (const entry of cache.values()) {
|
|
225
|
+
_emitCleanup();
|
|
226
|
+
entry.cleanup();
|
|
227
|
+
}
|
|
228
|
+
cache.clear();
|
|
229
|
+
curPos.clear();
|
|
230
|
+
currentKeyOrder = [];
|
|
231
|
+
clearBetween(startMarker, tailMarker);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const { newKeyOrder, newKeySet } = collectKeyOrder(newList);
|
|
235
|
+
removeStaleEntries(newKeySet);
|
|
236
|
+
mountNewEntries(newList);
|
|
237
|
+
if (currentKeyOrder.length > 0 && n > 0) lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, parent, tailMarker);
|
|
210
238
|
curPos.clear();
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
mountNewEntries(newList);
|
|
218
|
-
if (currentKeyOrder.length > 0 && n > 0) lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, parent, tailMarker);
|
|
219
|
-
curPos.clear();
|
|
220
|
-
for (let i = 0; i < newKeyOrder.length; i++) {
|
|
221
|
-
const k = newKeyOrder[i];
|
|
222
|
-
if (k !== void 0) curPos.set(k, i);
|
|
223
|
-
}
|
|
224
|
-
currentKeyOrder = newKeyOrder;
|
|
239
|
+
for (let i = 0; i < newKeyOrder.length; i++) {
|
|
240
|
+
const k = newKeyOrder[i];
|
|
241
|
+
if (k !== void 0) curPos.set(k, i);
|
|
242
|
+
}
|
|
243
|
+
currentKeyOrder = newKeyOrder;
|
|
244
|
+
});
|
|
225
245
|
});
|
|
226
246
|
return () => {
|
|
227
247
|
e.dispose();
|
|
228
248
|
for (const entry of cache.values()) {
|
|
249
|
+
_emitCleanup();
|
|
229
250
|
entry.cleanup();
|
|
230
251
|
entry.anchor.parentNode?.removeChild(entry.anchor);
|
|
231
252
|
}
|
|
@@ -284,7 +305,7 @@ function computeForLis(lis, n, newKeys, cache) {
|
|
|
284
305
|
tailIdx[lo] = i;
|
|
285
306
|
if (lo > 0) pred[i] = tailIdx[lo - 1];
|
|
286
307
|
}
|
|
287
|
-
if (__DEV__$2 && ops > 0) _countSink$
|
|
308
|
+
if (__DEV__$2 && ops > 0) _countSink$2.__pyreon_count__?.("runtime.mountFor.lisOps", ops);
|
|
288
309
|
return lisLen;
|
|
289
310
|
}
|
|
290
311
|
function applyForMoves(n, newKeys, stay, cache, liveParent, tailMarker) {
|
|
@@ -402,7 +423,10 @@ function mountFor(source, getKey, renderItem, parent, anchor, mountChild) {
|
|
|
402
423
|
};
|
|
403
424
|
const handleReplaceAll = (items, n, newKeys, liveParent) => {
|
|
404
425
|
if (cleanupCount > 0) {
|
|
405
|
-
for (const entry of cache.values()) if (entry.cleanup)
|
|
426
|
+
for (const entry of cache.values()) if (entry.cleanup) {
|
|
427
|
+
_emitCleanup();
|
|
428
|
+
entry.cleanup();
|
|
429
|
+
}
|
|
406
430
|
}
|
|
407
431
|
cache = /* @__PURE__ */ new Map();
|
|
408
432
|
cleanupCount = 0;
|
|
@@ -427,6 +451,7 @@ function mountFor(source, getKey, renderItem, parent, anchor, mountChild) {
|
|
|
427
451
|
for (const [key, entry] of cache) {
|
|
428
452
|
if (newKeySet.has(key)) continue;
|
|
429
453
|
if (entry.cleanup) {
|
|
454
|
+
_emitCleanup();
|
|
430
455
|
entry.cleanup();
|
|
431
456
|
cleanupCount--;
|
|
432
457
|
}
|
|
@@ -446,7 +471,10 @@ function mountFor(source, getKey, renderItem, parent, anchor, mountChild) {
|
|
|
446
471
|
const handleFastClear = (liveParent) => {
|
|
447
472
|
if (cache.size === 0) return;
|
|
448
473
|
if (cleanupCount > 0) {
|
|
449
|
-
for (const entry of cache.values()) if (entry.cleanup)
|
|
474
|
+
for (const entry of cache.values()) if (entry.cleanup) {
|
|
475
|
+
_emitCleanup();
|
|
476
|
+
entry.cleanup();
|
|
477
|
+
}
|
|
450
478
|
}
|
|
451
479
|
const pp = liveParent.parentNode;
|
|
452
480
|
if (pp && liveParent.firstChild === startMarker && liveParent.lastChild === tailMarker) {
|
|
@@ -484,25 +512,30 @@ function mountFor(source, getKey, renderItem, parent, anchor, mountChild) {
|
|
|
484
512
|
if (!liveParent) return;
|
|
485
513
|
const items = source();
|
|
486
514
|
const n = items.length;
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
515
|
+
runUntracked(() => {
|
|
516
|
+
if (n === 0) {
|
|
517
|
+
handleFastClear(liveParent);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (currentKeys.length === 0) {
|
|
521
|
+
handleFreshRender(items, n, liveParent);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const newKeys = collectNewKeys(items, n);
|
|
525
|
+
if (!hasAnyKeptKey(n, newKeys)) {
|
|
526
|
+
handleReplaceAll(items, n, newKeys, liveParent);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
handleIncrementalUpdate(items, n, newKeys, liveParent);
|
|
530
|
+
});
|
|
501
531
|
});
|
|
502
532
|
return () => {
|
|
503
533
|
e.dispose();
|
|
504
534
|
for (const entry of cache.values()) {
|
|
505
|
-
if (cleanupCount > 0 && entry.cleanup)
|
|
535
|
+
if (cleanupCount > 0 && entry.cleanup) {
|
|
536
|
+
_emitCleanup();
|
|
537
|
+
entry.cleanup();
|
|
538
|
+
}
|
|
506
539
|
entry.anchor.parentNode?.removeChild(entry.anchor);
|
|
507
540
|
}
|
|
508
541
|
cache = /* @__PURE__ */ new Map();
|
|
@@ -606,6 +639,7 @@ function delegatedPropName(eventName) {
|
|
|
606
639
|
//#endregion
|
|
607
640
|
//#region src/props.ts
|
|
608
641
|
const __DEV__$1 = process.env.NODE_ENV !== "production";
|
|
642
|
+
const _countSink$1 = globalThis;
|
|
609
643
|
let _customSanitizer = null;
|
|
610
644
|
const SAFE_TAGS = new Set([
|
|
611
645
|
"a",
|
|
@@ -729,7 +763,10 @@ function applyProps(el, props) {
|
|
|
729
763
|
let cleanups = null;
|
|
730
764
|
for (const key in props) {
|
|
731
765
|
if (key === "key" || key === "ref" || key === "children") continue;
|
|
732
|
-
const
|
|
766
|
+
const descriptor = Object.getOwnPropertyDescriptor(props, key);
|
|
767
|
+
let c;
|
|
768
|
+
if (descriptor?.get) c = renderEffect(() => applyStaticProp(el, key, props[key]));
|
|
769
|
+
else c = applyProp(el, key, props[key]);
|
|
733
770
|
if (c) if (!first) first = c;
|
|
734
771
|
else if (!cleanups) cleanups = [first, c];
|
|
735
772
|
else cleanups.push(c);
|
|
@@ -750,6 +787,7 @@ function applyProps(el, props) {
|
|
|
750
787
|
* Bind an event handler (onClick → "click") with batching + delegation support.
|
|
751
788
|
*/
|
|
752
789
|
function applyEventProp(el, key, value) {
|
|
790
|
+
if (__DEV__$1) _countSink$1.__pyreon_count__?.("runtime.applyEvent");
|
|
753
791
|
if (typeof value !== "function") {
|
|
754
792
|
if (__DEV__$1 && value != null) console.warn(`[Pyreon] Event handler "${key}" received a non-function value (${typeof value}). Expected a function. Did you mean ${key}={() => ...}?`);
|
|
755
793
|
return null;
|
|
@@ -796,6 +834,7 @@ function applyStaticProp(el, key, value) {
|
|
|
796
834
|
setStaticProp(el, key, value);
|
|
797
835
|
}
|
|
798
836
|
function applyProp(el, key, value) {
|
|
837
|
+
if (__DEV__$1) _countSink$1.__pyreon_count__?.("runtime.applyProp");
|
|
799
838
|
if (EVENT_RE.test(key)) return applyEventProp(el, key, value);
|
|
800
839
|
if (typeof value === "function") return renderEffect(() => applyStaticProp(el, key, value()));
|
|
801
840
|
applyStaticProp(el, key, value);
|
|
@@ -1320,7 +1359,7 @@ function KeepAlive(props) {
|
|
|
1320
1359
|
const e = effect(() => {
|
|
1321
1360
|
const isActive = props.active?.() ?? true;
|
|
1322
1361
|
if (!childMounted) {
|
|
1323
|
-
childCleanup = mountChild(props.children ?? null, container, null);
|
|
1362
|
+
childCleanup = runUntracked(() => mountChild(props.children ?? null, container, null));
|
|
1324
1363
|
childMounted = true;
|
|
1325
1364
|
}
|
|
1326
1365
|
container.style.display = isActive ? "" : "none";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/runtime-dom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "DOM renderer for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-dom#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -54,15 +54,15 @@
|
|
|
54
54
|
"prepublishOnly": "bun run build"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@pyreon/core": "^0.
|
|
58
|
-
"@pyreon/reactivity": "^0.
|
|
57
|
+
"@pyreon/core": "^0.18.0",
|
|
58
|
+
"@pyreon/reactivity": "^0.18.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
62
|
-
"@pyreon/compiler": "^0.
|
|
62
|
+
"@pyreon/compiler": "^0.18.0",
|
|
63
63
|
"@pyreon/manifest": "0.13.1",
|
|
64
|
-
"@pyreon/runtime-server": "^0.
|
|
65
|
-
"@pyreon/test-utils": "^0.13.
|
|
64
|
+
"@pyreon/runtime-server": "^0.18.0",
|
|
65
|
+
"@pyreon/test-utils": "^0.13.5",
|
|
66
66
|
"@vitest/browser-playwright": "^4.1.4",
|
|
67
67
|
"esbuild": "^0.28.0",
|
|
68
68
|
"happy-dom": "^20.8.3",
|
package/src/keep-alive.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Props, VNodeChild } from '@pyreon/core'
|
|
2
2
|
import { createRef, h, nativeCompat, onMount } from '@pyreon/core'
|
|
3
|
-
import { effect } from '@pyreon/reactivity'
|
|
3
|
+
import { effect, runUntracked } from '@pyreon/reactivity'
|
|
4
4
|
import { mountChild } from './mount'
|
|
5
5
|
|
|
6
6
|
export interface KeepAliveProps extends Props {
|
|
@@ -51,8 +51,15 @@ export function KeepAlive(props: KeepAliveProps): VNodeChild {
|
|
|
51
51
|
const isActive = props.active?.() ?? true
|
|
52
52
|
|
|
53
53
|
if (!childMounted) {
|
|
54
|
-
// Mount children
|
|
55
|
-
|
|
54
|
+
// Mount children UNTRACKED — child component setup must not
|
|
55
|
+
// subscribe this effect. Otherwise an unrelated signal flip in
|
|
56
|
+
// the children would re-run KeepAlive's effect, runCleanup()
|
|
57
|
+
// would dispose the children's inner effects (because they were
|
|
58
|
+
// collected as inner effects of this run via _innerEffectCollector),
|
|
59
|
+
// and the `if (!childMounted)` guard would skip re-mount → the
|
|
60
|
+
// children become permanently un-reactive while still rendered.
|
|
61
|
+
// Same shape as the mountFor / mountKeyedList fix in nodes.ts.
|
|
62
|
+
childCleanup = runUntracked(() => mountChild(props.children ?? null, container, null))
|
|
56
63
|
childMounted = true
|
|
57
64
|
}
|
|
58
65
|
|
package/src/nodes.ts
CHANGED
|
@@ -37,6 +37,11 @@ function clearBetween(start: Node, end: Node): void {
|
|
|
37
37
|
// frag goes out of scope → nodes are GC-eligible
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** Emit `runtime.cleanup` once per registered mount cleanup that actually runs. */
|
|
41
|
+
function _emitCleanup(): void {
|
|
42
|
+
if (__DEV__) _countSink.__pyreon_count__?.('runtime.cleanup')
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
/**
|
|
41
46
|
* Mount a reactive node whose content changes over time.
|
|
42
47
|
*
|
|
@@ -49,6 +54,7 @@ export function mountReactive(
|
|
|
49
54
|
anchor: Node | null,
|
|
50
55
|
mount: (child: VNodeChild, p: Node, a: Node | null) => Cleanup,
|
|
51
56
|
): Cleanup {
|
|
57
|
+
if (__DEV__) _countSink.__pyreon_count__?.('runtime.mountReactive')
|
|
52
58
|
const marker = document.createComment('pyreon')
|
|
53
59
|
parent.insertBefore(marker, anchor)
|
|
54
60
|
|
|
@@ -61,6 +67,9 @@ export function mountReactive(
|
|
|
61
67
|
let currentCleanup: Cleanup = () => {
|
|
62
68
|
/* noop */
|
|
63
69
|
}
|
|
70
|
+
// hasCleanup gates `runtime.cleanup` so we don't count the placeholder
|
|
71
|
+
// noop on the first effect run as a "cleanup invocation".
|
|
72
|
+
let hasCleanup = false
|
|
64
73
|
let generation = 0
|
|
65
74
|
|
|
66
75
|
const e = effect(() => {
|
|
@@ -68,10 +77,12 @@ export function mountReactive(
|
|
|
68
77
|
// Run cleanup outside tracking context — cleanup may write to signals
|
|
69
78
|
// (e.g. onUnmount hooks), and those writes must not accidentally register
|
|
70
79
|
// as dependencies of this effect, which would cause infinite recursion.
|
|
80
|
+
if (hasCleanup) _emitCleanup()
|
|
71
81
|
runUntracked(() => currentCleanup())
|
|
72
82
|
currentCleanup = () => {
|
|
73
83
|
/* noop */
|
|
74
84
|
}
|
|
85
|
+
hasCleanup = false
|
|
75
86
|
const value = accessor()
|
|
76
87
|
// Note: typeof value === 'function' is a VALID return from a reactive
|
|
77
88
|
// accessor — it represents a nested `() => VNodeChild` accessor (the
|
|
@@ -98,7 +109,9 @@ export function mountReactive(
|
|
|
98
109
|
// set by the re-entrant run.
|
|
99
110
|
if (myGen === generation) {
|
|
100
111
|
currentCleanup = cleanup
|
|
112
|
+
hasCleanup = true
|
|
101
113
|
} else {
|
|
114
|
+
_emitCleanup()
|
|
102
115
|
cleanup()
|
|
103
116
|
}
|
|
104
117
|
}
|
|
@@ -106,6 +119,7 @@ export function mountReactive(
|
|
|
106
119
|
|
|
107
120
|
return () => {
|
|
108
121
|
e.dispose()
|
|
122
|
+
if (hasCleanup) _emitCleanup()
|
|
109
123
|
currentCleanup()
|
|
110
124
|
marker.parentNode?.removeChild(marker)
|
|
111
125
|
}
|
|
@@ -270,6 +284,7 @@ export function mountKeyedList(
|
|
|
270
284
|
const removeStaleEntries = (newKeySet: Set<string | number>) => {
|
|
271
285
|
for (const [key, entry] of cache) {
|
|
272
286
|
if (newKeySet.has(key)) continue
|
|
287
|
+
_emitCleanup()
|
|
273
288
|
entry.cleanup()
|
|
274
289
|
entry.anchor.parentNode?.removeChild(entry.anchor)
|
|
275
290
|
cache.delete(key)
|
|
@@ -293,35 +308,42 @@ export function mountKeyedList(
|
|
|
293
308
|
const e = effect(() => {
|
|
294
309
|
const newList = accessor()
|
|
295
310
|
const n = newList.length
|
|
311
|
+
// Same untracking rationale as mountFor — see comment there. Child
|
|
312
|
+
// mounts via mountVNode must not re-track on this effect's run.
|
|
313
|
+
runUntracked(() => {
|
|
314
|
+
if (n === 0 && cache.size > 0) {
|
|
315
|
+
for (const entry of cache.values()) {
|
|
316
|
+
_emitCleanup()
|
|
317
|
+
entry.cleanup()
|
|
318
|
+
}
|
|
319
|
+
cache.clear()
|
|
320
|
+
curPos.clear()
|
|
321
|
+
currentKeyOrder = []
|
|
322
|
+
clearBetween(startMarker, tailMarker)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
296
325
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
curPos.clear()
|
|
301
|
-
currentKeyOrder = []
|
|
302
|
-
clearBetween(startMarker, tailMarker)
|
|
303
|
-
return
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const { newKeyOrder, newKeySet } = collectKeyOrder(newList)
|
|
307
|
-
removeStaleEntries(newKeySet)
|
|
308
|
-
mountNewEntries(newList)
|
|
326
|
+
const { newKeyOrder, newKeySet } = collectKeyOrder(newList)
|
|
327
|
+
removeStaleEntries(newKeySet)
|
|
328
|
+
mountNewEntries(newList)
|
|
309
329
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
330
|
+
if (currentKeyOrder.length > 0 && n > 0) {
|
|
331
|
+
lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, parent, tailMarker)
|
|
332
|
+
}
|
|
313
333
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
334
|
+
curPos.clear()
|
|
335
|
+
for (let i = 0; i < newKeyOrder.length; i++) {
|
|
336
|
+
const k = newKeyOrder[i]
|
|
337
|
+
if (k !== undefined) curPos.set(k, i)
|
|
338
|
+
}
|
|
339
|
+
currentKeyOrder = newKeyOrder
|
|
340
|
+
})
|
|
320
341
|
})
|
|
321
342
|
|
|
322
343
|
return () => {
|
|
323
344
|
e.dispose()
|
|
324
345
|
for (const entry of cache.values()) {
|
|
346
|
+
_emitCleanup()
|
|
325
347
|
entry.cleanup()
|
|
326
348
|
entry.anchor.parentNode?.removeChild(entry.anchor)
|
|
327
349
|
}
|
|
@@ -602,7 +624,12 @@ export function mountFor<T>(
|
|
|
602
624
|
liveParent: Node,
|
|
603
625
|
) => {
|
|
604
626
|
if (cleanupCount > 0) {
|
|
605
|
-
for (const entry of cache.values())
|
|
627
|
+
for (const entry of cache.values()) {
|
|
628
|
+
if (entry.cleanup) {
|
|
629
|
+
_emitCleanup()
|
|
630
|
+
entry.cleanup()
|
|
631
|
+
}
|
|
632
|
+
}
|
|
606
633
|
}
|
|
607
634
|
cache = new Map()
|
|
608
635
|
cleanupCount = 0
|
|
@@ -634,6 +661,7 @@ export function mountFor<T>(
|
|
|
634
661
|
for (const [key, entry] of cache) {
|
|
635
662
|
if (newKeySet.has(key)) continue
|
|
636
663
|
if (entry.cleanup) {
|
|
664
|
+
_emitCleanup()
|
|
637
665
|
entry.cleanup()
|
|
638
666
|
cleanupCount--
|
|
639
667
|
}
|
|
@@ -660,7 +688,12 @@ export function mountFor<T>(
|
|
|
660
688
|
const handleFastClear = (liveParent: Node) => {
|
|
661
689
|
if (cache.size === 0) return
|
|
662
690
|
if (cleanupCount > 0) {
|
|
663
|
-
for (const entry of cache.values())
|
|
691
|
+
for (const entry of cache.values()) {
|
|
692
|
+
if (entry.cleanup) {
|
|
693
|
+
_emitCleanup()
|
|
694
|
+
entry.cleanup()
|
|
695
|
+
}
|
|
696
|
+
}
|
|
664
697
|
}
|
|
665
698
|
const pp = liveParent.parentNode
|
|
666
699
|
if (pp && liveParent.firstChild === startMarker && liveParent.lastChild === tailMarker) {
|
|
@@ -715,31 +748,45 @@ export function mountFor<T>(
|
|
|
715
748
|
if (!liveParent) return
|
|
716
749
|
const items = source()
|
|
717
750
|
const n = items.length
|
|
751
|
+
// Child mounts (renderInto → mountChild) must NOT re-track on this
|
|
752
|
+
// effect's run, mirroring mountReactive's pattern at line ~92. Without
|
|
753
|
+
// this, any signal read during a child component's setup (e.g. useQuery
|
|
754
|
+
// calling `new QueryObserver(client, options())` at construction time,
|
|
755
|
+
// which reads any signals inside the options builder) leaks its
|
|
756
|
+
// subscription up to the For effect. A flip of the unrelated signal
|
|
757
|
+
// re-runs For, runCleanup() disposes ALL inner effects, and
|
|
758
|
+
// handleIncrementalUpdate skips re-mount on key match — leaving the
|
|
759
|
+
// subtree's inner effects gone forever. Reproduced by the
|
|
760
|
+
// `<For>`-shaped test in fanout-repro.test.tsx; deferred from PR #490.
|
|
761
|
+
runUntracked(() => {
|
|
762
|
+
if (n === 0) {
|
|
763
|
+
handleFastClear(liveParent)
|
|
764
|
+
return
|
|
765
|
+
}
|
|
718
766
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
if (currentKeys.length === 0) {
|
|
725
|
-
handleFreshRender(items, n, liveParent)
|
|
726
|
-
return
|
|
727
|
-
}
|
|
767
|
+
if (currentKeys.length === 0) {
|
|
768
|
+
handleFreshRender(items, n, liveParent)
|
|
769
|
+
return
|
|
770
|
+
}
|
|
728
771
|
|
|
729
|
-
|
|
772
|
+
const newKeys = collectNewKeys(items, n)
|
|
730
773
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
774
|
+
if (!hasAnyKeptKey(n, newKeys)) {
|
|
775
|
+
handleReplaceAll(items, n, newKeys, liveParent)
|
|
776
|
+
return
|
|
777
|
+
}
|
|
735
778
|
|
|
736
|
-
|
|
779
|
+
handleIncrementalUpdate(items, n, newKeys, liveParent)
|
|
780
|
+
})
|
|
737
781
|
})
|
|
738
782
|
|
|
739
783
|
return () => {
|
|
740
784
|
e.dispose()
|
|
741
785
|
for (const entry of cache.values()) {
|
|
742
|
-
if (cleanupCount > 0 && entry.cleanup)
|
|
786
|
+
if (cleanupCount > 0 && entry.cleanup) {
|
|
787
|
+
_emitCleanup()
|
|
788
|
+
entry.cleanup()
|
|
789
|
+
}
|
|
743
790
|
entry.anchor.parentNode?.removeChild(entry.anchor)
|
|
744
791
|
}
|
|
745
792
|
cache = new Map()
|
package/src/props.ts
CHANGED
|
@@ -10,6 +10,9 @@ type Cleanup = () => void
|
|
|
10
10
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
11
11
|
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
12
12
|
|
|
13
|
+
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
14
|
+
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
15
|
+
|
|
13
16
|
// ─── Configurable sanitizer ──────────────────────────────────────────────────
|
|
14
17
|
|
|
15
18
|
export type SanitizeFn = (html: string) => string
|
|
@@ -176,7 +179,23 @@ export function applyProps(el: Element, props: Props): Cleanup | null {
|
|
|
176
179
|
let cleanups: Cleanup[] | null = null
|
|
177
180
|
for (const key in props) {
|
|
178
181
|
if (key === 'key' || key === 'ref' || key === 'children') continue
|
|
179
|
-
|
|
182
|
+
// Getter-shaped descriptors are produced by `makeReactiveProps` from
|
|
183
|
+
// compiler-emitted `_rp(() => signal())` wrappers. A plain
|
|
184
|
+
// `props[key]` read fires the getter once at mount time and stores
|
|
185
|
+
// the resolved value — breaking signal-driven reactivity. Detecting
|
|
186
|
+
// the descriptor and wrapping the read in `renderEffect` here is
|
|
187
|
+
// equivalent to applyProp's existing function-value branch (line 322),
|
|
188
|
+
// routed through the descriptor instead of the value. Other prop
|
|
189
|
+
// pipelines (`splitProps`, `mergeProps`, rocketstyle's
|
|
190
|
+
// descriptor-preserving merges) keep the getter intact end-to-end;
|
|
191
|
+
// this is the final consumer that closes the loop.
|
|
192
|
+
const descriptor = Object.getOwnPropertyDescriptor(props, key)
|
|
193
|
+
let c: Cleanup | null
|
|
194
|
+
if (descriptor?.get) {
|
|
195
|
+
c = renderEffect(() => applyStaticProp(el, key, (props as Record<string, unknown>)[key]))
|
|
196
|
+
} else {
|
|
197
|
+
c = applyProp(el, key, props[key])
|
|
198
|
+
}
|
|
180
199
|
if (c) {
|
|
181
200
|
if (!first) {
|
|
182
201
|
first = c
|
|
@@ -205,6 +224,7 @@ export function applyProps(el: Element, props: Props): Cleanup | null {
|
|
|
205
224
|
* Bind an event handler (onClick → "click") with batching + delegation support.
|
|
206
225
|
*/
|
|
207
226
|
function applyEventProp(el: Element, key: string, value: unknown): Cleanup | null {
|
|
227
|
+
if (__DEV__) _countSink.__pyreon_count__?.('runtime.applyEvent')
|
|
208
228
|
if (typeof value !== 'function') {
|
|
209
229
|
// `undefined` and `null` are legitimate — conditional handler pattern:
|
|
210
230
|
// <button onClick={condition ? handler : undefined}>
|
|
@@ -295,7 +315,13 @@ function applyStaticProp(el: Element, key: string, value: unknown): void {
|
|
|
295
315
|
setStaticProp(el, key, value)
|
|
296
316
|
}
|
|
297
317
|
|
|
318
|
+
// `runtime.applyProp` fires for EVERY prop key, including events. `runtime.applyEvent`
|
|
319
|
+
// fires only for `on*` props — strict subset. Useful diagnostic ratios:
|
|
320
|
+
// applyEvent / applyProp = event-handler density per element
|
|
321
|
+
// applyProp - applyEvent = static / reactive attr density
|
|
322
|
+
// Don't subtract them and treat as disjoint.
|
|
298
323
|
export function applyProp(el: Element, key: string, value: unknown): Cleanup | null {
|
|
324
|
+
if (__DEV__) _countSink.__pyreon_count__?.('runtime.applyProp')
|
|
299
325
|
// Event listener: onClick → "click"
|
|
300
326
|
if (EVENT_RE.test(key)) return applyEventProp(el, key, value)
|
|
301
327
|
|
package/src/template.ts
CHANGED
|
@@ -71,6 +71,7 @@ export function _bindText(
|
|
|
71
71
|
source: { _v?: unknown; direct?: (fn: () => void) => () => void },
|
|
72
72
|
node: Text,
|
|
73
73
|
): () => void {
|
|
74
|
+
if (__DEV__) _countSink.__pyreon_count__?.('runtime.bindText')
|
|
74
75
|
// Fast path: source has .direct() (signal or computed)
|
|
75
76
|
if (source.direct) {
|
|
76
77
|
const textUpdate = () => {
|
|
@@ -112,6 +113,7 @@ export function _bindDirect(
|
|
|
112
113
|
source: { _v?: unknown; direct?: (fn: () => void) => () => void },
|
|
113
114
|
updater: (value: unknown) => void,
|
|
114
115
|
): () => void {
|
|
116
|
+
if (__DEV__) _countSink.__pyreon_count__?.('runtime.bindDirect')
|
|
115
117
|
// Fast path: source has .direct() (signal or computed)
|
|
116
118
|
if (source.direct) {
|
|
117
119
|
updater(source._v)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/** @jsxImportSource @pyreon/core */
|
|
2
|
+
/**
|
|
3
|
+
* Reproduction of the deferred bug from PR #490 (queryReactiveKey-1000 journey).
|
|
4
|
+
*
|
|
5
|
+
* The pure-reactivity unit test (packages/core/reactivity/src/tests/fanout-repro.test.ts)
|
|
6
|
+
* passes — many effects subscribing to one signal all fire on every external
|
|
7
|
+
* .set. So the bug must involve actual Pyreon mount frames (provide/onMount/etc),
|
|
8
|
+
* not just the reactivity primitives.
|
|
9
|
+
*
|
|
10
|
+
* This test mounts a real Pyreon component with N effects subscribing to a
|
|
11
|
+
* shared signal, then writes to that signal in a tight external loop —
|
|
12
|
+
* mirroring the real shape from the queryReactiveKey-1000 journey.
|
|
13
|
+
*/
|
|
14
|
+
import { For, h } from '@pyreon/core'
|
|
15
|
+
import { effect, signal } from '@pyreon/reactivity'
|
|
16
|
+
import { describe, expect, it } from 'vitest'
|
|
17
|
+
import { mount } from '../index'
|
|
18
|
+
|
|
19
|
+
describe('signal fan-out under tight external write loop — INSIDE mount frame', () => {
|
|
20
|
+
it('100 effects in a Pyreon component — each fires on every external .set', () => {
|
|
21
|
+
const sig = signal(0)
|
|
22
|
+
const counts = new Array(100).fill(0)
|
|
23
|
+
|
|
24
|
+
const root = document.createElement('div')
|
|
25
|
+
document.body.appendChild(root)
|
|
26
|
+
|
|
27
|
+
const Component = () => {
|
|
28
|
+
for (let i = 0; i < 100; i++) {
|
|
29
|
+
const idx = i
|
|
30
|
+
effect(() => {
|
|
31
|
+
sig()
|
|
32
|
+
counts[idx]++
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
return h('div', null, 'mounted')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const dispose = mount(h(Component, null), root)
|
|
39
|
+
|
|
40
|
+
// All 100 effects ran their initial setup.
|
|
41
|
+
for (const c of counts) expect(c).toBe(1)
|
|
42
|
+
|
|
43
|
+
// 10 external writes outside any batch.
|
|
44
|
+
for (let i = 1; i <= 10; i++) sig.set(i)
|
|
45
|
+
|
|
46
|
+
// Each effect should have re-fired 10 more times → total = 11.
|
|
47
|
+
let failed = 0
|
|
48
|
+
for (let i = 0; i < counts.length; i++) {
|
|
49
|
+
if (counts[i] !== 11) failed++
|
|
50
|
+
}
|
|
51
|
+
expect(failed, `effects with wrong count (out of 100)`).toBe(0)
|
|
52
|
+
|
|
53
|
+
dispose()
|
|
54
|
+
root.remove()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('100 effects + an extra effect AFTER the loop — all fire on each .set', () => {
|
|
58
|
+
// Real-bug shape: a diagnostic effect placed AFTER the useQuery loop
|
|
59
|
+
// saw 0 re-runs across 10 flips, while a BEFORE-loop one saw 1 of 10.
|
|
60
|
+
const sig = signal(0)
|
|
61
|
+
const counts = new Array(100).fill(0)
|
|
62
|
+
let beforeRuns = 0
|
|
63
|
+
let afterRuns = 0
|
|
64
|
+
|
|
65
|
+
const root = document.createElement('div')
|
|
66
|
+
document.body.appendChild(root)
|
|
67
|
+
|
|
68
|
+
const Component = () => {
|
|
69
|
+
effect(() => {
|
|
70
|
+
sig()
|
|
71
|
+
beforeRuns++
|
|
72
|
+
})
|
|
73
|
+
for (let i = 0; i < 100; i++) {
|
|
74
|
+
const idx = i
|
|
75
|
+
effect(() => {
|
|
76
|
+
sig()
|
|
77
|
+
counts[idx]++
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
effect(() => {
|
|
81
|
+
sig()
|
|
82
|
+
afterRuns++
|
|
83
|
+
})
|
|
84
|
+
return h('div', null, 'mounted')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const dispose = mount(h(Component, null), root)
|
|
88
|
+
|
|
89
|
+
expect(beforeRuns).toBe(1)
|
|
90
|
+
expect(afterRuns).toBe(1)
|
|
91
|
+
for (const c of counts) expect(c).toBe(1)
|
|
92
|
+
|
|
93
|
+
for (let i = 1; i <= 10; i++) sig.set(i)
|
|
94
|
+
|
|
95
|
+
expect(beforeRuns, 'before-loop effect runs').toBe(11)
|
|
96
|
+
expect(afterRuns, 'after-loop effect runs').toBe(11)
|
|
97
|
+
let failed = 0
|
|
98
|
+
for (let i = 0; i < counts.length; i++) {
|
|
99
|
+
if (counts[i] !== 11) failed++
|
|
100
|
+
}
|
|
101
|
+
expect(failed, `effects with wrong count`).toBe(0)
|
|
102
|
+
|
|
103
|
+
dispose()
|
|
104
|
+
root.remove()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('the body of each effect ALSO writes to a per-effect local signal (mimics useQuery slot writes)', () => {
|
|
108
|
+
// useQuery's effect body calls observer.setOptions which triggers the
|
|
109
|
+
// observer's subscribe callback which does batch(() => 9 signal.sets).
|
|
110
|
+
// Approximate that with: each outer effect's body creates its own local
|
|
111
|
+
// signal and writes to it in a batch.
|
|
112
|
+
const sig = signal(0)
|
|
113
|
+
const counts = new Array(100).fill(0)
|
|
114
|
+
|
|
115
|
+
const root = document.createElement('div')
|
|
116
|
+
document.body.appendChild(root)
|
|
117
|
+
|
|
118
|
+
const Component = () => {
|
|
119
|
+
for (let i = 0; i < 100; i++) {
|
|
120
|
+
const idx = i
|
|
121
|
+
const slot = signal('')
|
|
122
|
+
effect(() => {
|
|
123
|
+
sig() // subscribe
|
|
124
|
+
// Mimic batched writes inside the effect body
|
|
125
|
+
slot.set(`run-${counts[idx]}`)
|
|
126
|
+
counts[idx]++
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
return h('div', null, 'mounted')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const dispose = mount(h(Component, null), root)
|
|
133
|
+
|
|
134
|
+
for (const c of counts) expect(c).toBe(1)
|
|
135
|
+
|
|
136
|
+
for (let i = 1; i <= 10; i++) sig.set(i)
|
|
137
|
+
|
|
138
|
+
let failed = 0
|
|
139
|
+
for (let i = 0; i < counts.length; i++) {
|
|
140
|
+
if (counts[i] !== 11) failed++
|
|
141
|
+
}
|
|
142
|
+
expect(failed, `effects with wrong count`).toBe(0)
|
|
143
|
+
|
|
144
|
+
dispose()
|
|
145
|
+
root.remove()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// ─── The real bug shape: <For> wrapping the queries ───────────────────────
|
|
149
|
+
//
|
|
150
|
+
// mountFor (`packages/core/runtime-dom/src/nodes.ts`) wraps its body in
|
|
151
|
+
// effect() but does NOT untrack the child mountChild calls (mountReactive
|
|
152
|
+
// does — line 92 of nodes.ts). As a result, any signal read during a
|
|
153
|
+
// child component's setup tracks against the For effect's run. When the
|
|
154
|
+
// tracked signal flips, For's effect re-runs → runCleanup() disposes ALL
|
|
155
|
+
// inner effects (the per-item setOptions effects) → handleIncrementalUpdate
|
|
156
|
+
// sees keys unchanged → does not re-mount → setOptions effects gone +
|
|
157
|
+
// never recreated. signalWrite fires N times but effectRun stays at the
|
|
158
|
+
// initial-mount count — the exact PR #490 observation.
|
|
159
|
+
|
|
160
|
+
it('REGRESSION: 100 effects mounted under <For> re-fire when shared signal flips', () => {
|
|
161
|
+
// Mirrors the queryReactiveKey-1000 shape: a mode/count tuple keys the
|
|
162
|
+
// For so its body mounts QueryAtScale-equivalent ONCE. Inside, N effects
|
|
163
|
+
// subscribe to a separate `reactKey` signal. External flips of reactKey
|
|
164
|
+
// must propagate to all N inner effects.
|
|
165
|
+
const reactKey = signal(0)
|
|
166
|
+
const counts = new Array(100).fill(0)
|
|
167
|
+
const root = document.createElement('div')
|
|
168
|
+
document.body.appendChild(root)
|
|
169
|
+
|
|
170
|
+
// Stable single-item array — For mounts the inner component exactly once.
|
|
171
|
+
const items = [{ id: 1 }] as const
|
|
172
|
+
type Item = (typeof items)[number]
|
|
173
|
+
|
|
174
|
+
const Inner = (props: { item: Item }) => {
|
|
175
|
+
// Read props.item to keep the component honest — same shape as
|
|
176
|
+
// QueryAtScale (which reads props.mode + props.count). That read
|
|
177
|
+
// tracks against the OUTER For effect via makeReactiveProps' getter.
|
|
178
|
+
void props.item.id
|
|
179
|
+
|
|
180
|
+
// Mimic useQuery's "read signal at construction time, OUTSIDE the
|
|
181
|
+
// inner effect" pattern. This is what `new QueryObserver(client,
|
|
182
|
+
// options())` does — options() reads reactKey while activeEffect is
|
|
183
|
+
// the outer effect (For's run), leaking the subscription up.
|
|
184
|
+
const _seed = reactKey() // ← this is the leak
|
|
185
|
+
|
|
186
|
+
// Plus: 100 effects each subscribing to reactKey via their own bodies.
|
|
187
|
+
for (let i = 0; i < 100; i++) {
|
|
188
|
+
const idx = i
|
|
189
|
+
effect(() => {
|
|
190
|
+
reactKey()
|
|
191
|
+
counts[idx]++
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
return h('div', { 'data-testid': 'inner' }, `mounted ${_seed}`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const dispose = mount(
|
|
198
|
+
h(For, {
|
|
199
|
+
each: items,
|
|
200
|
+
by: (it: Item) => it.id,
|
|
201
|
+
children: (it: Item) => h(Inner, { item: it }),
|
|
202
|
+
}),
|
|
203
|
+
root,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
for (const c of counts) expect(c).toBe(1)
|
|
207
|
+
|
|
208
|
+
for (let i = 1; i <= 10; i++) reactKey.set(i)
|
|
209
|
+
|
|
210
|
+
let failed = 0
|
|
211
|
+
for (let i = 0; i < counts.length; i++) {
|
|
212
|
+
if (counts[i] !== 11) failed++
|
|
213
|
+
}
|
|
214
|
+
expect(failed, `effects with wrong count after 10 flips`).toBe(0)
|
|
215
|
+
|
|
216
|
+
dispose()
|
|
217
|
+
root.remove()
|
|
218
|
+
})
|
|
219
|
+
})
|
package/src/tests/mount.test.ts
CHANGED
|
@@ -1611,7 +1611,7 @@ describe('mount — edge cases', () => {
|
|
|
1611
1611
|
|
|
1612
1612
|
test('mounting array of children', () => {
|
|
1613
1613
|
const el = container()
|
|
1614
|
-
mount(h('div', null,
|
|
1614
|
+
mount(h('div', null, h('span', null, 'a'), h('span', null, 'b'), h('span', null, 'c')), el)
|
|
1615
1615
|
expect(el.querySelectorAll('span').length).toBe(3)
|
|
1616
1616
|
})
|
|
1617
1617
|
|
|
@@ -118,8 +118,16 @@ describe('Transition', () => {
|
|
|
118
118
|
const target = el.querySelector('.lifecycle') as HTMLElement
|
|
119
119
|
if (target) {
|
|
120
120
|
target.dispatchEvent(new Event('transitionend'))
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
// Poll for the assertion instead of fixed sleep. Fixed setTimeout
|
|
122
|
+
// is structurally flaky on shared CI runners: scheduling latency
|
|
123
|
+
// between dispatchEvent's callback queue and the next tick can
|
|
124
|
+
// exceed any reasonable fixed wait (we tried 10ms then 50ms, both
|
|
125
|
+
// flaked). `vi.waitFor` polls every 10ms up to the timeout, so it
|
|
126
|
+
// settles as soon as the assertion holds while still bounding the
|
|
127
|
+
// worst case.
|
|
128
|
+
await vi.waitFor(() => expect(onAfterEnter).toHaveBeenCalled(), {
|
|
129
|
+
timeout: 2000,
|
|
130
|
+
})
|
|
123
131
|
}
|
|
124
132
|
})
|
|
125
133
|
|
package/src/transition-group.ts
CHANGED
|
@@ -199,12 +199,22 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
199
199
|
const key = props.keyFn(item, i)
|
|
200
200
|
if (entries.has(key)) continue
|
|
201
201
|
const itemRef = createRef<HTMLElement>()
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
202
|
+
// Both render AND mountChild must run untracked — child component
|
|
203
|
+
// setup (signal reads inside the render callback's resulting tree,
|
|
204
|
+
// useTheme / useQuery's options() construction etc.) must NOT
|
|
205
|
+
// subscribe this effect. Otherwise an unrelated signal flip re-runs
|
|
206
|
+
// the TransitionGroup effect, runCleanup() disposes the children's
|
|
207
|
+
// inner effects, and the next mount path skips re-rendering kept
|
|
208
|
+
// entries → the inner reactivity is lost. Same shape as the
|
|
209
|
+
// mountFor / mountKeyedList fix in nodes.ts.
|
|
210
|
+
const cleanup = runUntracked(() => {
|
|
211
|
+
const rawVNode = props.render(item, i)
|
|
212
|
+
const vnode: VNode =
|
|
213
|
+
typeof rawVNode.type === 'string'
|
|
214
|
+
? { ...rawVNode, props: { ...rawVNode.props, ref: itemRef } as Props }
|
|
215
|
+
: rawVNode
|
|
216
|
+
return mountChild(vnode, container, null)
|
|
217
|
+
})
|
|
208
218
|
const entry: ItemEntry = { key, ref: itemRef, cleanup, leaving: false, cancelTransition: null }
|
|
209
219
|
entries.set(key, entry)
|
|
210
220
|
newEntries.push(entry)
|