what-core 0.8.4 → 0.10.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.
Files changed (55) hide show
  1. package/dist/chunk-AW3BAPIK.js +1685 -0
  2. package/dist/chunk-AW3BAPIK.js.map +7 -0
  3. package/dist/chunk-AZP2EOGX.js +188 -0
  4. package/dist/chunk-AZP2EOGX.js.map +7 -0
  5. package/dist/chunk-F2HUXI22.js +1675 -0
  6. package/dist/chunk-F2HUXI22.js.map +7 -0
  7. package/dist/chunk-KBM6CWG4.min.js +2 -0
  8. package/dist/chunk-KBM6CWG4.min.js.map +7 -0
  9. package/dist/chunk-KL7TNUIU.min.js +2 -0
  10. package/dist/chunk-KL7TNUIU.min.js.map +7 -0
  11. package/dist/chunk-L6XOF7P4.min.js +2 -0
  12. package/dist/chunk-L6XOF7P4.min.js.map +7 -0
  13. package/dist/chunk-M7UEET5O.js +1323 -0
  14. package/dist/chunk-M7UEET5O.js.map +7 -0
  15. package/dist/chunk-O3SKPRTY.min.js +2 -0
  16. package/dist/chunk-O3SKPRTY.min.js.map +7 -0
  17. package/dist/chunk-RN6QIBWL.min.js +2 -0
  18. package/dist/chunk-RN6QIBWL.min.js.map +7 -0
  19. package/dist/chunk-VMTTYB4L.min.js +2 -0
  20. package/dist/chunk-VMTTYB4L.min.js.map +7 -0
  21. package/dist/chunk-VP4WLF5A.js +1323 -0
  22. package/dist/chunk-VP4WLF5A.js.map +7 -0
  23. package/dist/chunk-YA3W4XKH.js +1323 -0
  24. package/dist/chunk-YA3W4XKH.js.map +7 -0
  25. package/dist/index.js +212 -2785
  26. package/dist/index.js.map +4 -4
  27. package/dist/index.min.js +6 -6
  28. package/dist/index.min.js.map +4 -4
  29. package/dist/jsx-dev-runtime.js +4 -53
  30. package/dist/jsx-dev-runtime.js.map +3 -3
  31. package/dist/jsx-dev-runtime.min.js +1 -1
  32. package/dist/jsx-dev-runtime.min.js.map +4 -4
  33. package/dist/jsx-runtime.js +4 -53
  34. package/dist/jsx-runtime.js.map +3 -3
  35. package/dist/jsx-runtime.min.js +1 -1
  36. package/dist/jsx-runtime.min.js.map +4 -4
  37. package/dist/render.js +22 -2044
  38. package/dist/render.js.map +4 -4
  39. package/dist/render.min.js +1 -1
  40. package/dist/render.min.js.map +4 -4
  41. package/dist/testing.js +13 -1079
  42. package/dist/testing.js.map +4 -4
  43. package/dist/testing.min.js +1 -1
  44. package/dist/testing.min.js.map +4 -4
  45. package/package.json +2 -2
  46. package/src/dom.js +54 -6
  47. package/src/h.js +15 -3
  48. package/src/head.js +72 -2
  49. package/src/hooks.js +65 -4
  50. package/src/hydration-data.js +34 -0
  51. package/src/index.js +9 -2
  52. package/src/reactive.js +78 -1
  53. package/src/render.js +450 -105
  54. package/src/server-context.js +48 -0
  55. package/src/store.js +6 -2
package/src/render.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // No VDOM diffing — direct DOM manipulation with surgical signal-driven updates.
4
4
 
5
5
  import { effect, untrack, createRoot, _createItemScope, signal, __DEV__ } from './reactive.js';
6
- import { createDOM, disposeTree, getCurrentComponent, getComponentStack } from './dom.js';
6
+ import { createDOM, disposeTree, getCurrentComponent, getComponentStack, _setSelectValue } from './dom.js';
7
7
  export { effect, untrack };
8
8
 
9
9
  // --- Generic text insertion hook ---
@@ -167,6 +167,11 @@ export function svgTemplate(html) {
167
167
  // - array → insert each element
168
168
 
169
169
  export function insert(parent, child, marker) {
170
+ // mapArray inserter: self-managing reactive list with its own effect
171
+ if (typeof child === 'function' && child._mapArray) {
172
+ return child(parent, marker || null);
173
+ }
174
+
170
175
  if (typeof child === 'function') {
171
176
  // Fast path: if the first evaluation returns a string/number, optimistically
172
177
  // create a text node for direct updates. If the value type changes later
@@ -197,8 +202,10 @@ export function insert(parent, child, marker) {
197
202
  });
198
203
  return textNode;
199
204
  }
200
- // General path for non-text reactive children (first value was null/vnode/array)
201
- let current = first != null ? reconcileInsert(parent, first, null, marker || null) : null;
205
+ // General path for non-text reactive children (first value was null/vnode/array).
206
+ // Let the effect handle both the initial insert and subsequent updates to avoid
207
+ // double-evaluating child() (which would create components twice on mount).
208
+ let current = null;
202
209
  effect(() => {
203
210
  current = reconcileInsert(parent, child(), current, marker || null);
204
211
  });
@@ -256,13 +263,28 @@ function valuesToNodes(value, parent, out) {
256
263
  return out;
257
264
  }
258
265
 
266
+ // Resolve function values (reactive accessors passed through props)
267
+ if (typeof value === 'function') {
268
+ valuesToNodes(value(), parent, out);
269
+ return out;
270
+ }
271
+
259
272
  if (typeof value === 'string' || typeof value === 'number') {
260
273
  out.push(document.createTextNode(String(value)));
261
274
  return out;
262
275
  }
263
276
 
264
277
  if (isDomNode(value)) {
265
- out.push(value);
278
+ // DocumentFragments lose their children on DOM insertion, making them
279
+ // untrackable for reconciliation. Flatten to child nodes instead.
280
+ if (value.nodeType === 11 && value.childNodes.length > 0) {
281
+ const children = Array.from(value.childNodes);
282
+ for (let i = 0; i < children.length; i++) {
283
+ out.push(children[i]);
284
+ }
285
+ } else {
286
+ out.push(value);
287
+ }
266
288
  return out;
267
289
  }
268
290
 
@@ -383,7 +405,7 @@ export function mapArray(source, mapFn, options) {
383
405
  const keyFn = options?.key;
384
406
  const raw = options?.raw || false;
385
407
 
386
- return (parent, marker) => {
408
+ const inserter = (parent, marker) => {
387
409
  let items = [];
388
410
  let mappedNodes = [];
389
411
  let disposeFns = [];
@@ -407,6 +429,8 @@ export function mapArray(source, mapFn, options) {
407
429
 
408
430
  return endMarker;
409
431
  };
432
+ inserter._mapArray = true;
433
+ return inserter;
410
434
  }
411
435
 
412
436
  function reconcileList(parent, endMarker, oldItems, newItems, mappedNodes, disposeFns, mapFn) {
@@ -424,10 +448,10 @@ function reconcileList(parent, endMarker, oldItems, newItems, mappedNodes, dispo
424
448
  for (let i = oldLen - 1; i >= 0; i--) {
425
449
  const node = mappedNodes[i];
426
450
  if (node) {
427
- // Only walk subtree if the node has reactive state not tracked by createRoot
428
- if (node._componentCtx || node._dispose || node._propEffects) {
429
- disposeTree(node);
430
- }
451
+ // disposeTree walks the subtree for nested component contexts
452
+ // (c:start comments) and reactive bindings that the item-scope
453
+ // dispose above does not cover. (AUDIT C5)
454
+ disposeTree(node);
431
455
  if (node.parentNode === parent) parent.removeChild(node);
432
456
  }
433
457
  }
@@ -490,6 +514,7 @@ function reconcileList(parent, endMarker, oldItems, newItems, mappedNodes, dispo
490
514
  // Only removals in the middle
491
515
  for (let i = start; i <= oldEnd; i++) {
492
516
  disposeFns[i]?.();
517
+ if (mappedNodes[i]) disposeTree(mappedNodes[i]); // dispose nested component ctx (AUDIT C5)
493
518
  if (mappedNodes[i]?.parentNode) mappedNodes[i].parentNode.removeChild(mappedNodes[i]);
494
519
  }
495
520
  } else if (midOldLen === 0) {
@@ -547,6 +572,7 @@ function _reconcileMiddle(parent, endMarker, oldItems, newItems, mappedNodes, di
547
572
  // Dispose removed items
548
573
  for (const [, oldIdx] of oldIdxMap) {
549
574
  disposeFns[oldIdx]?.();
575
+ if (mappedNodes[oldIdx]) disposeTree(mappedNodes[oldIdx]); // dispose nested component ctx (AUDIT C5)
550
576
  if (mappedNodes[oldIdx]?.parentNode) mappedNodes[oldIdx].parentNode.removeChild(mappedNodes[oldIdx]);
551
577
  }
552
578
 
@@ -655,6 +681,73 @@ function _lis(arr, len) {
655
681
  // When a key persists but its item reference changes, the item signal updates
656
682
  // in place — no DOM node destruction/creation. Only effects reading the
657
683
  // item accessor re-run (e.g., textContent update for changed label).
684
+ //
685
+ // Multi-node items: Components return DocumentFragments (c:start, content, c:end).
686
+ // We track each item via a start-marker comment. Moving/removing an item moves
687
+ // all nodes from its marker up to (but not including) the next item's marker.
688
+
689
+ function _createItemMarker() {
690
+ return document.createComment('i');
691
+ }
692
+
693
+ // Collect all DOM nodes belonging to one item (from its marker to beforeEnd).
694
+ function _collectItemNodes(marker, beforeEnd) {
695
+ const nodes = [];
696
+ let n = marker;
697
+ while (n && n !== beforeEnd) {
698
+ nodes.push(n);
699
+ n = n.nextSibling;
700
+ }
701
+ return nodes;
702
+ }
703
+
704
+ // Move all nodes for an item (starting at marker) before `ref` in `parent`.
705
+ function _moveItem(parent, marker, beforeEnd, ref) {
706
+ let n = marker;
707
+ while (n && n !== beforeEnd) {
708
+ const next = n.nextSibling;
709
+ parent.insertBefore(n, ref);
710
+ n = next;
711
+ }
712
+ }
713
+
714
+ // Remove all nodes for an item from the DOM.
715
+ function _removeItemNodes(parent, marker, beforeEnd) {
716
+ let n = marker;
717
+ while (n && n !== beforeEnd) {
718
+ const next = n.nextSibling;
719
+ // Always disposeTree: a component's context lives on its `c:start` comment
720
+ // (nodeType 8, via _commentCtxMap) which carries none of the gate flags
721
+ // below, so the old `_componentCtx || _dispose || _propEffects` guard
722
+ // leaked every component's effects/cleanups/onCleanup/listeners on removal.
723
+ // disposeTree is internally cheap-guarded and idempotent. (AUDIT C5)
724
+ disposeTree(n);
725
+ parent.removeChild(n);
726
+ n = next;
727
+ }
728
+ }
729
+
730
+ // Create a new item: wraps mapFn result in a marker + appends to target.
731
+ function _createKeyedItem(target, item, idx, keyFn, keyedState, mapFn, mappedArr, disposeArr, signal_) {
732
+ let accessor;
733
+ if (keyedState) {
734
+ const key = keyFn(item);
735
+ const itemSig = signal_(item);
736
+ accessor = itemSig;
737
+ keyedState.set(key, { itemSig });
738
+ } else {
739
+ accessor = item;
740
+ }
741
+ const marker = _createItemMarker();
742
+ target.appendChild(marker);
743
+ const result = _createItemScope(dispose => {
744
+ disposeArr[idx] = dispose;
745
+ return mapFn(accessor, idx);
746
+ });
747
+ // result may be a DocumentFragment or a single node
748
+ target.appendChild(result);
749
+ mappedArr[idx] = marker;
750
+ }
658
751
 
659
752
  function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disposeFns, mapFn, keyFn, keyedState) {
660
753
  const newLen = newItems.length;
@@ -663,18 +756,12 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
663
756
  // --- Fast path: clear all ---
664
757
  if (newLen === 0) {
665
758
  if (oldLen > 0) {
666
- // Dispose reactive scopes first, then remove DOM nodes.
667
759
  for (let i = 0; i < oldLen; i++) {
668
760
  if (disposeFns[i]) disposeFns[i]();
669
761
  }
670
- for (let i = oldLen - 1; i >= 0; i--) {
671
- const node = mappedNodes[i];
672
- if (node) {
673
- if (node._componentCtx || node._dispose || node._propEffects) {
674
- disposeTree(node);
675
- }
676
- if (node.parentNode === parent) parent.removeChild(node);
677
- }
762
+ // Remove all nodes between first item marker and endMarker
763
+ if (mappedNodes[0]) {
764
+ _removeItemNodes(parent, mappedNodes[0], endMarker);
678
765
  }
679
766
  mappedNodes.length = 0;
680
767
  disposeFns.length = 0;
@@ -687,23 +774,7 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
687
774
  if (oldLen === 0) {
688
775
  const frag = document.createDocumentFragment();
689
776
  for (let i = 0; i < newLen; i++) {
690
- const item = newItems[i];
691
- const idx = i;
692
- let accessor;
693
- if (keyedState) {
694
- const key = keyFn(item);
695
- const itemSig = signal(item);
696
- accessor = itemSig;
697
- keyedState.set(key, { itemSig });
698
- } else {
699
- accessor = item; // raw mode: pass item directly
700
- }
701
- const node = _createItemScope(dispose => {
702
- disposeFns[idx] = dispose;
703
- return mapFn(accessor, idx);
704
- });
705
- mappedNodes[i] = node;
706
- frag.appendChild(node);
777
+ _createKeyedItem(frag, newItems[i], i, keyFn, keyedState, mapFn, mappedNodes, disposeFns, signal);
707
778
  }
708
779
  parent.insertBefore(frag, endMarker);
709
780
  return;
@@ -713,12 +784,10 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
713
784
  let start = 0;
714
785
  const minLen = Math.min(oldLen, newLen);
715
786
  while (start < minLen) {
716
- // Fast path: same reference → same key, no update needed
717
787
  if (oldItems[start] === newItems[start]) { start++; continue; }
718
788
  const oldKey = keyFn(oldItems[start]);
719
789
  const newKey = keyFn(newItems[start]);
720
790
  if (oldKey !== newKey) break;
721
- // Key matches but reference changed — update signal (non-raw mode only)
722
791
  if (keyedState) keyedState.get(oldKey).itemSig.set(newItems[start]);
723
792
  start++;
724
793
  }
@@ -736,13 +805,8 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
736
805
  newEnd--;
737
806
  }
738
807
 
739
- // If everything matched, nothing to do
740
- if (start > oldEnd && start > newEnd) {
741
- // Just copy existing mappings to output
742
- return;
743
- }
808
+ if (start > oldEnd && start > newEnd) return;
744
809
 
745
- // Copy prefix/suffix into output arrays
746
810
  const newMapped = new Array(newLen);
747
811
  const newDispose = new Array(newLen);
748
812
  for (let i = 0; i < start; i++) {
@@ -760,27 +824,12 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
760
824
 
761
825
  // --- Only additions in middle ---
762
826
  if (midOldLen === 0) {
763
- const marker = newEnd + 1 < newLen && newMapped[newEnd + 1] ? newMapped[newEnd + 1] : endMarker;
827
+ const ref = newEnd + 1 < newLen && newMapped[newEnd + 1] ? newMapped[newEnd + 1] : endMarker;
764
828
  const frag = document.createDocumentFragment();
765
829
  for (let i = start; i <= newEnd; i++) {
766
- const item = newItems[i];
767
- const idx = i;
768
- let accessor;
769
- if (keyedState) {
770
- const key = keyFn(item);
771
- const itemSig = signal(item);
772
- accessor = itemSig;
773
- keyedState.set(key, { itemSig });
774
- } else {
775
- accessor = item;
776
- }
777
- newMapped[i] = _createItemScope(dispose => {
778
- newDispose[idx] = dispose;
779
- return mapFn(accessor, idx);
780
- });
781
- frag.appendChild(newMapped[i]);
830
+ _createKeyedItem(frag, newItems[i], i, keyFn, keyedState, mapFn, newMapped, newDispose, signal);
782
831
  }
783
- parent.insertBefore(frag, marker);
832
+ parent.insertBefore(frag, ref);
784
833
  _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen);
785
834
  return;
786
835
  }
@@ -789,15 +838,243 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
789
838
  if (midNewLen === 0) {
790
839
  for (let i = start; i <= oldEnd; i++) {
791
840
  disposeFns[i]?.();
792
- if (mappedNodes[i]?.parentNode) parent.removeChild(mappedNodes[i]);
841
+ // Compute the range boundary from the live DOM. Sibling markers in
842
+ // mappedNodes may have been detached by earlier iterations of this loop;
843
+ // walking the DOM finds the next surviving item marker (or endMarker).
844
+ const rangeEnd = _findNextMarkerAfter(parent, mappedNodes[i], mappedNodes, i, endMarker);
845
+ _removeItemNodes(parent, mappedNodes[i], rangeEnd);
793
846
  if (keyedState) keyedState.delete(keyFn(oldItems[i]));
794
847
  }
795
848
  _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen);
796
849
  return;
797
850
  }
798
851
 
852
+ // --- Fast paths for common small-move cases ---
853
+ // Detect swap (2 mismatches) or single-move (contiguous shift) cheaply
854
+ // before falling through to the expensive LIS + backward-walk general case.
855
+
856
+ if (midNewLen === midOldLen && midNewLen >= 2 && midNewLen <= Math.max(midOldLen, 200)) {
857
+ // Count positions where keys differ
858
+ let mismatchCount = 0;
859
+ let mm1 = -1, mm2 = -1; // first two mismatch indices (relative to start)
860
+ for (let i = 0; i < midNewLen && mismatchCount <= 4; i++) {
861
+ const oldKey = keyFn(oldItems[start + i]);
862
+ const newKey = keyFn(newItems[start + i]);
863
+ if (oldKey !== newKey) {
864
+ if (mismatchCount === 0) mm1 = i;
865
+ else if (mismatchCount === 1) mm2 = i;
866
+ mismatchCount++;
867
+ }
868
+ }
869
+
870
+ // --- Fast path A: Pure swap (exactly 2 key mismatches, keys exchanged) ---
871
+ if (mismatchCount === 2) {
872
+ const i1 = start + mm1, i2 = start + mm2;
873
+ const oldKey1 = keyFn(oldItems[i1]), oldKey2 = keyFn(oldItems[i2]);
874
+ const newKey1 = keyFn(newItems[i1]), newKey2 = keyFn(newItems[i2]);
875
+
876
+ if (oldKey1 === newKey2 && oldKey2 === newKey1) {
877
+ // Confirmed swap. Move item at i2's DOM position before item at i1's position,
878
+ // then move i1's nodes to where i2 was.
879
+ for (let i = 0; i < start; i++) {
880
+ newMapped[i] = mappedNodes[i];
881
+ newDispose[i] = disposeFns[i];
882
+ }
883
+ for (let i = start; i <= newEnd; i++) {
884
+ newMapped[i] = mappedNodes[i];
885
+ newDispose[i] = disposeFns[i];
886
+ }
887
+ for (let i = newEnd + 1; i < newLen; i++) {
888
+ const oldI = oldEnd + 1 + (i - newEnd - 1);
889
+ newMapped[i] = mappedNodes[oldI];
890
+ newDispose[i] = disposeFns[oldI];
891
+ }
892
+
893
+ // Swap mapped entries
894
+ const tmpM = newMapped[i1]; newMapped[i1] = newMapped[i2]; newMapped[i2] = tmpM;
895
+ const tmpD = newDispose[i1]; newDispose[i1] = newDispose[i2]; newDispose[i2] = tmpD;
896
+
897
+ // Update keyed state signals if item references differ
898
+ if (keyedState) {
899
+ if (newItems[i1] !== oldItems[i1]) {
900
+ const k = keyFn(newItems[i1]);
901
+ const entry = keyedState.get(k);
902
+ if (entry) entry.itemSig.set(newItems[i1]);
903
+ }
904
+ if (newItems[i2] !== oldItems[i2]) {
905
+ const k = keyFn(newItems[i2]);
906
+ const entry = keyedState.get(k);
907
+ if (entry) entry.itemSig.set(newItems[i2]);
908
+ }
909
+ }
910
+
911
+ // DOM moves: swap the two items' DOM ranges.
912
+ // Adjacent swaps need special handling because moving item2 before
913
+ // item1 invalidates the pre-computed end boundary for item1 (it was
914
+ // item2's marker, which has now moved). For adjacent items, a single
915
+ // _moveItem suffices. For non-adjacent items, we recompute end1 after
916
+ // the first move.
917
+ const isAdjacent = (i2 === i1 + 1) || (i1 === i2 + 1);
918
+ const lo = Math.min(i1, i2), hi = Math.max(i1, i2);
919
+
920
+ if (isAdjacent) {
921
+ // Adjacent: just move the later item's nodes before the earlier item's marker.
922
+ const endHi = _findNextMarkerAfter(parent, mappedNodes[hi], mappedNodes, hi, endMarker);
923
+ _moveItem(parent, mappedNodes[hi], endHi, mappedNodes[lo]);
924
+ } else {
925
+ // Non-adjacent: use a placeholder to remember i2's position, then
926
+ // recompute end1 after the first move (since DOM has changed).
927
+ const end2 = _findNextMarkerAfter(parent, mappedNodes[i2], mappedNodes, i2, endMarker);
928
+
929
+ const placeholder = document.createComment('tmp');
930
+ parent.insertBefore(placeholder, mappedNodes[i2]);
931
+
932
+ // Move i2's nodes to before i1's current position
933
+ _moveItem(parent, mappedNodes[i2], end2, mappedNodes[i1]);
934
+ // Recompute end1 — the DOM has changed, so the pre-move boundary is stale
935
+ const end1 = _findNextMarkerAfter(parent, mappedNodes[i1], mappedNodes, i1, endMarker);
936
+ // Move i1's nodes to where i2 was (before placeholder)
937
+ _moveItem(parent, mappedNodes[i1], end1, placeholder);
938
+ parent.removeChild(placeholder);
939
+ }
940
+
941
+ _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen);
942
+ return;
943
+ }
944
+ }
945
+
946
+ // --- Fast path B: Single item relocated ---
947
+ // One item removed from position `from` and inserted at position `to`,
948
+ // everything between shifted by one.
949
+ if (mismatchCount >= 2 && mismatchCount <= midNewLen) {
950
+ // Try to detect single-move pattern:
951
+ // If we remove element at `from` in old and insert at `to` in new,
952
+ // the rest should match.
953
+ // Forward move: old[from] = new[to], old[from+1..to] = new[from..to-1]
954
+ // Backward move: old[from] = new[to], old[to..from-1] = new[to+1..from]
955
+
956
+ const fromRel = mm1; // first mismatch - the moved item was here in old OR went here in new
957
+ let movedKey = null;
958
+ let fromAbs = -1, toAbs = -1;
959
+ let isMove = false;
960
+
961
+ // Check forward move: item at old[start+fromRel] moved later
962
+ const candidateKey = keyFn(oldItems[start + fromRel]);
963
+ // Find where this key ended up in new
964
+ let destRel = -1;
965
+ for (let i = fromRel; i < midNewLen; i++) {
966
+ if (keyFn(newItems[start + i]) === candidateKey) { destRel = i; break; }
967
+ }
968
+ if (destRel > fromRel) {
969
+ // Verify: old[fromRel+1..destRel] should match new[fromRel..destRel-1]
970
+ let match = true;
971
+ for (let i = fromRel; i < destRel; i++) {
972
+ if (keyFn(oldItems[start + i + 1]) !== keyFn(newItems[start + i])) { match = false; break; }
973
+ }
974
+ if (match) {
975
+ // And everything after destRel should be the same
976
+ let afterMatch = true;
977
+ for (let i = destRel + 1; i < midNewLen; i++) {
978
+ if (keyFn(oldItems[start + i]) !== keyFn(newItems[start + i])) { afterMatch = false; break; }
979
+ }
980
+ if (afterMatch) {
981
+ isMove = true;
982
+ fromAbs = start + fromRel;
983
+ toAbs = start + destRel;
984
+ movedKey = candidateKey;
985
+ }
986
+ }
987
+ }
988
+
989
+ if (!isMove) {
990
+ // Check backward move: item from later in old moved to start+fromRel in new
991
+ const candidateKey2 = keyFn(newItems[start + fromRel]);
992
+ let srcRel = -1;
993
+ for (let i = fromRel; i < midOldLen; i++) {
994
+ if (keyFn(oldItems[start + i]) === candidateKey2) { srcRel = i; break; }
995
+ }
996
+ if (srcRel > fromRel) {
997
+ // Verify: old[fromRel..srcRel-1] should match new[fromRel+1..srcRel]
998
+ let match = true;
999
+ for (let i = fromRel; i < srcRel; i++) {
1000
+ if (keyFn(oldItems[start + i]) !== keyFn(newItems[start + i + 1])) { match = false; break; }
1001
+ }
1002
+ if (match) {
1003
+ let afterMatch = true;
1004
+ for (let i = srcRel + 1; i < midNewLen; i++) {
1005
+ if (keyFn(oldItems[start + i]) !== keyFn(newItems[start + i])) { afterMatch = false; break; }
1006
+ }
1007
+ if (afterMatch) {
1008
+ isMove = true;
1009
+ fromAbs = start + srcRel;
1010
+ toAbs = start + fromRel;
1011
+ movedKey = candidateKey2;
1012
+ }
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ if (isMove) {
1018
+ // Copy all mapped/dispose to new arrays
1019
+ for (let i = start; i <= oldEnd; i++) {
1020
+ newMapped[i] = mappedNodes[i];
1021
+ newDispose[i] = disposeFns[i];
1022
+ }
1023
+
1024
+ // Shift entries in newMapped/newDispose to reflect the move
1025
+ const movedMarker = newMapped[fromAbs];
1026
+ const movedDispose = newDispose[fromAbs];
1027
+
1028
+ if (fromAbs < toAbs) {
1029
+ // Forward move: shift [from+1..to] left by 1
1030
+ for (let i = fromAbs; i < toAbs; i++) {
1031
+ newMapped[i] = newMapped[i + 1];
1032
+ newDispose[i] = newDispose[i + 1];
1033
+ }
1034
+ } else {
1035
+ // Backward move: shift [to..from-1] right by 1
1036
+ for (let i = fromAbs; i > toAbs; i--) {
1037
+ newMapped[i] = newMapped[i - 1];
1038
+ newDispose[i] = newDispose[i - 1];
1039
+ }
1040
+ }
1041
+ newMapped[toAbs] = movedMarker;
1042
+ newDispose[toAbs] = movedDispose;
1043
+
1044
+ // Update keyed state signals for items whose references changed
1045
+ if (keyedState) {
1046
+ for (let i = start; i <= newEnd; i++) {
1047
+ const key = keyFn(newItems[i]);
1048
+ if (newItems[i] !== oldItems[i]) {
1049
+ // Only look up oldItems[i] by key if index is in old range
1050
+ const entry = keyedState.get(key);
1051
+ if (entry) entry.itemSig.set(newItems[i]);
1052
+ }
1053
+ }
1054
+ }
1055
+
1056
+ // Single DOM move: move the item's nodes to its new position
1057
+ const movedEnd = _findNextMarkerAfter(parent, movedMarker, mappedNodes, fromAbs, endMarker);
1058
+ // Find the reference node: the marker of the item that should come AFTER the moved item
1059
+ let ref;
1060
+ if (toAbs + 1 < newLen) {
1061
+ ref = newMapped[toAbs + 1];
1062
+ } else {
1063
+ ref = endMarker;
1064
+ }
1065
+ // For suffix items, use the actual mapped marker
1066
+ if (toAbs >= newEnd + 1 || (ref && ref.parentNode !== parent)) {
1067
+ ref = endMarker;
1068
+ }
1069
+ _moveItem(parent, movedMarker, movedEnd, ref);
1070
+
1071
+ _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen);
1072
+ return;
1073
+ }
1074
+ }
1075
+ }
1076
+
799
1077
  // --- General case: reconcile middle section ---
800
- // Build old key → old index map for middle section only
801
1078
  const oldKeyMap = new Map();
802
1079
  for (let i = start; i <= oldEnd; i++) {
803
1080
  oldKeyMap.set(keyFn(oldItems[i]), i);
@@ -806,7 +1083,6 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
806
1083
  const oldIndices = new Int32Array(midNewLen);
807
1084
  oldIndices.fill(-1);
808
1085
 
809
- // Match by key
810
1086
  for (let i = start; i <= newEnd; i++) {
811
1087
  const key = keyFn(newItems[i]);
812
1088
  const oldIdx = oldKeyMap.get(key);
@@ -815,43 +1091,35 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
815
1091
  newMapped[i] = mappedNodes[oldIdx];
816
1092
  newDispose[i] = disposeFns[oldIdx];
817
1093
  oldIndices[i - start] = oldIdx;
818
- // Update item signal if reference changed (non-raw mode only)
819
1094
  if (keyedState && newItems[i] !== oldItems[oldIdx]) {
820
1095
  keyedState.get(key).itemSig.set(newItems[i]);
821
1096
  }
822
1097
  }
823
1098
  }
824
1099
 
825
- // Dispose removed items
826
- for (const [key, oldIdx] of oldKeyMap) {
1100
+ // Dispose removed items (iterate in reverse to avoid shifting boundaries)
1101
+ const removedIndices = [...oldKeyMap.values()].sort((a, b) => b - a);
1102
+ for (const oldIdx of removedIndices) {
827
1103
  disposeFns[oldIdx]?.();
828
- if (mappedNodes[oldIdx]?.parentNode) parent.removeChild(mappedNodes[oldIdx]);
829
- if (keyedState) keyedState.delete(key);
1104
+ // Compute the range boundary from the live DOM. Adjacent removals can
1105
+ // detach mappedNodes[oldIdx + 1] before we get here, so we cannot trust
1106
+ // that reference — walk the DOM to find the next surviving item marker.
1107
+ const rangeEnd = _findNextMarkerAfter(parent, mappedNodes[oldIdx], mappedNodes, oldIdx, endMarker);
1108
+ _removeItemNodes(parent, mappedNodes[oldIdx], rangeEnd);
1109
+ if (keyedState) keyedState.delete(keyFn(oldItems[oldIdx]));
830
1110
  }
831
1111
 
832
- // Create new items
1112
+ // Create new items (into a detached fragment, then positioned below)
833
1113
  for (let i = start; i <= newEnd; i++) {
834
1114
  if (!newMapped[i]) {
835
- const item = newItems[i];
836
- const idx = i;
837
- let accessor;
838
- if (keyedState) {
839
- const key = keyFn(item);
840
- const itemSig = signal(item);
841
- accessor = itemSig;
842
- keyedState.set(key, { itemSig });
843
- } else {
844
- accessor = item;
845
- }
846
- newMapped[i] = _createItemScope(dispose => {
847
- newDispose[idx] = dispose;
848
- return mapFn(accessor, idx);
849
- });
1115
+ const frag = document.createDocumentFragment();
1116
+ _createKeyedItem(frag, newItems[i], i, keyFn, keyedState, mapFn, newMapped, newDispose, signal);
1117
+ // Leave in frag for now — will be positioned in the move pass
1118
+ newMapped[i]._frag = frag;
850
1119
  }
851
1120
  }
852
1121
 
853
1122
  // Position using LIS
854
- // First check: are reused items already in order? (common for update-in-place)
855
1123
  let reusedCount = 0;
856
1124
  let alreadySorted = true;
857
1125
  let lastOldIdx = -1;
@@ -866,7 +1134,6 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
866
1134
  const inLIS = new Uint8Array(midNewLen);
867
1135
 
868
1136
  if (alreadySorted) {
869
- // All reused items are in order — mark all as in LIS (no moves needed)
870
1137
  for (let i = 0; i < midNewLen; i++) {
871
1138
  if (oldIndices[i] !== -1) inLIS[i] = 1;
872
1139
  }
@@ -891,21 +1158,48 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
891
1158
  }
892
1159
  }
893
1160
 
894
- // Position: work backwards, insert items not in LIS
895
- let nextSibling = newEnd + 1 < newMapped.length && newMapped[newEnd + 1]
896
- ? newMapped[newEnd + 1] : endMarker;
1161
+ // Position: work backwards, move items not in LIS
1162
+ // For existing items: move all nodes from marker to next-item boundary.
1163
+ // For new items: insert from their detached fragment.
1164
+ // We rebuild the output array to reflect final positions.
1165
+ _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen);
897
1166
 
1167
+ // Start ref at the first suffix item's marker (not endMarker) so moved items
1168
+ // land before the suffix, not after it.
1169
+ let ref = newEnd + 1 < newLen && mappedNodes[newEnd + 1]
1170
+ ? mappedNodes[newEnd + 1] : endMarker;
898
1171
  for (let i = newEnd; i >= start; i--) {
899
1172
  const mi = i - start;
900
- if (oldIndices[mi] === -1 || !inLIS[mi]) {
901
- // Guard against stale nextSibling from nested reconciliation
902
- if (nextSibling && nextSibling.parentNode !== parent) nextSibling = endMarker;
903
- parent.insertBefore(newMapped[i], nextSibling);
1173
+ const marker = mappedNodes[i];
1174
+
1175
+ if (oldIndices[mi] === -1) {
1176
+ // New item — insert from detached fragment
1177
+ if (marker._frag) {
1178
+ parent.insertBefore(marker._frag, ref);
1179
+ delete marker._frag;
1180
+ }
1181
+ } else if (!inLIS[mi]) {
1182
+ // Existing item not in LIS — move all its nodes
1183
+ const nextItemMarker = _findNextMarkerAfter(parent, marker, mappedNodes, i, endMarker);
1184
+ _moveItem(parent, marker, nextItemMarker, ref);
904
1185
  }
905
- nextSibling = newMapped[i];
1186
+ ref = marker;
906
1187
  }
1188
+ }
907
1189
 
908
- _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen);
1190
+ // TODO(perf): cache item end boundary on marker if large keyed reorders show O(n²) hot paths.
1191
+ // Find the boundary end for an item's nodes in the current DOM.
1192
+ // Walks from the marker's nextSibling until we hit another item's marker or endMarker.
1193
+ function _findNextMarkerAfter(parent, marker, mappedNodes, idx, endMarker) {
1194
+ // The item's nodes end at the next sibling that is either:
1195
+ // - another item's marker comment (data === 'i')
1196
+ // - the list endMarker (data === '/list')
1197
+ let n = marker.nextSibling;
1198
+ while (n && n !== endMarker) {
1199
+ if (n.nodeType === 8 && n.data === 'i') return n;
1200
+ n = n.nextSibling;
1201
+ }
1202
+ return endMarker;
909
1203
  }
910
1204
 
911
1205
  function _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen) {
@@ -933,18 +1227,31 @@ export function spread(el, props) {
933
1227
  }
934
1228
 
935
1229
  if (typeof value === 'function' && !key.startsWith('on')) {
936
- // Reactive prop — create micro-effect
1230
+ // Reactive prop — create micro-effect. The disposer must be registered
1231
+ // on el._propEffects so disposeTree() (dom.js) tears it down when the
1232
+ // element unmounts; otherwise the effect keeps firing on signal writes
1233
+ // for a detached element. Mirror the setProp() pattern.
1234
+ if (!el._propEffects) el._propEffects = {};
1235
+ // If a previous spread/setProp already registered an effect for this
1236
+ // key, dispose it first to avoid double-tracking.
1237
+ if (el._propEffects[key]) {
1238
+ try { el._propEffects[key](); } catch (e) { /* already disposed */ }
1239
+ }
937
1240
  if (key === 'class' || key === 'className') {
938
- effect(() => { el.className = value() || ''; });
1241
+ el._propEffects[key] = effect(() => {
1242
+ const cls = value() || '';
1243
+ if (_hasSVGElement && el instanceof SVGElement) el.setAttribute('class', cls);
1244
+ else el.className = cls;
1245
+ });
939
1246
  } else if (key === 'style' && typeof value() === 'object') {
940
- effect(() => {
1247
+ el._propEffects[key] = effect(() => {
941
1248
  const styles = value();
942
1249
  for (const prop in styles) {
943
1250
  el.style[prop] = styles[prop] ?? '';
944
1251
  }
945
1252
  });
946
1253
  } else {
947
- effect(() => { setProp(el, key, value()); });
1254
+ el._propEffects[key] = effect(() => { setProp(el, key, value()); });
948
1255
  }
949
1256
  } else {
950
1257
  // Static prop
@@ -953,6 +1260,15 @@ export function spread(el, props) {
953
1260
  }
954
1261
  }
955
1262
 
1263
+ // NOTE: this is the fine-grained-compiler path's setProp. A second
1264
+ // implementation lives in dom.js (h()/diff path). See the longer note above
1265
+ // the dom.js version. Key differences vs. dom.js setProp:
1266
+ // - assumes events are handled by the compiler (delegation or direct
1267
+ // addEventListener) — no el._events bookkeeping here.
1268
+ // - sanitizes URL attributes (href/src) against javascript: protocol.
1269
+ // - enforces innerHTML must be { __html: ... } — plain strings are warned.
1270
+ // Both share the el._propEffects[key] disposer convention so disposeTree()
1271
+ // can tear down reactive prop effects on unmount.
956
1272
  export function setProp(el, key, value) {
957
1273
  // Ref handling — assign element to ref object/callback (defense in depth)
958
1274
  if (key === 'ref') {
@@ -964,6 +1280,19 @@ export function setProp(el, key, value) {
964
1280
  // Key prop — no-op, WhatFW has no virtual DOM (defense in depth, issue #6)
965
1281
  if (key === 'key') return;
966
1282
 
1283
+ // Reactive accessor: function values on non-event props are treated as
1284
+ // reactive getters. Wrap in an effect so the prop auto-updates. Track the
1285
+ // disposer on el._propEffects so disposeTree() tears it down on unmount —
1286
+ // mirrors the pattern in dom.js setProp / spread().
1287
+ if (typeof value === 'function' && !key.startsWith('on')) {
1288
+ if (!el._propEffects) el._propEffects = {};
1289
+ if (el._propEffects[key]) {
1290
+ try { el._propEffects[key](); } catch (e) { /* already disposed */ }
1291
+ }
1292
+ el._propEffects[key] = effect(() => setProp(el, key, value()));
1293
+ return;
1294
+ }
1295
+
967
1296
  // Sanitize URL attributes — reject dangerous protocols
968
1297
  if (URL_ATTRS.has(key) || URL_ATTRS.has(key.toLowerCase())) {
969
1298
  if (!isSafeUrl(value)) {
@@ -974,21 +1303,33 @@ export function setProp(el, key, value) {
974
1303
  }
975
1304
  }
976
1305
 
1306
+ const isSvg = _hasSVGElement && el instanceof SVGElement;
1307
+
977
1308
  if (key === 'class' || key === 'className') {
978
- el.className = value || '';
1309
+ if (isSvg) {
1310
+ el.setAttribute('class', value || '');
1311
+ } else {
1312
+ el.className = value || '';
1313
+ }
979
1314
  } else if (key === 'dangerouslySetInnerHTML') {
980
- el.innerHTML = value?.__html ?? '';
1315
+ const html = value?.__html ?? '';
1316
+ if (typeof __DEV__ !== 'undefined' && __DEV__ && typeof html === 'string' && /(<script|onerror\s*=|onload\s*=|javascript:)/i.test(html)) {
1317
+ console.warn('[what] dangerouslySetInnerHTML contains potential XSS vectors. Ensure content is sanitized.');
1318
+ }
1319
+ el.innerHTML = html;
981
1320
  } else if (key === 'innerHTML') {
982
1321
  if (value && typeof value === 'object' && '__html' in value) {
983
- el.innerHTML = value.__html ?? '';
1322
+ const html = value.__html ?? '';
1323
+ if (typeof __DEV__ !== 'undefined' && __DEV__ && typeof html === 'string' && /(<script|onerror\s*=|onload\s*=|javascript:)/i.test(html)) {
1324
+ console.warn('[what] dangerouslySetInnerHTML contains potential XSS vectors. Ensure content is sanitized.');
1325
+ }
1326
+ el.innerHTML = html;
984
1327
  } else {
985
- // Plain string innerHTML is rejected for security — use { __html: string } form
986
1328
  if (typeof console !== 'undefined' && value != null && value !== '') {
987
1329
  console.warn(
988
1330
  '[what] Plain string innerHTML is not allowed. Use { __html: "..." } or dangerouslySetInnerHTML={{ __html: "..." }} instead.'
989
1331
  );
990
1332
  }
991
- // Ignored — do not set innerHTML from plain string
992
1333
  }
993
1334
  } else if (key === 'style') {
994
1335
  if (typeof value === 'string') {
@@ -1003,6 +1344,10 @@ export function setProp(el, key, value) {
1003
1344
  } else if (typeof value === 'boolean') {
1004
1345
  if (value) el.setAttribute(key, '');
1005
1346
  else el.removeAttribute(key);
1347
+ } else if (isSvg) {
1348
+ el.setAttribute(key, value);
1349
+ } else if (key === 'value' && el.tagName === 'SELECT') {
1350
+ _setSelectValue(el, value);
1006
1351
  } else if (key in el) {
1007
1352
  el[key] = value;
1008
1353
  } else {