what-core 0.8.4 → 0.11.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 (78) hide show
  1. package/dist/chunk-2IZMPODD.min.js +2 -0
  2. package/dist/chunk-2IZMPODD.min.js.map +7 -0
  3. package/dist/chunk-2P7OVL2L.js +1386 -0
  4. package/dist/chunk-2P7OVL2L.js.map +7 -0
  5. package/dist/chunk-5EQUBJWQ.js +1365 -0
  6. package/dist/chunk-5EQUBJWQ.js.map +7 -0
  7. package/dist/chunk-6DAIK77K.min.js +2 -0
  8. package/dist/chunk-6DAIK77K.min.js.map +7 -0
  9. package/dist/chunk-AW3BAPIK.js +1685 -0
  10. package/dist/chunk-AW3BAPIK.js.map +7 -0
  11. package/dist/chunk-AZP2EOGX.js +188 -0
  12. package/dist/chunk-AZP2EOGX.js.map +7 -0
  13. package/dist/chunk-CCINITLW.js +1692 -0
  14. package/dist/chunk-CCINITLW.js.map +7 -0
  15. package/dist/chunk-F2HUXI22.js +1675 -0
  16. package/dist/chunk-F2HUXI22.js.map +7 -0
  17. package/dist/chunk-GZRA4IAJ.js +1699 -0
  18. package/dist/chunk-GZRA4IAJ.js.map +7 -0
  19. package/dist/chunk-H3GA34JK.js +1384 -0
  20. package/dist/chunk-H3GA34JK.js.map +7 -0
  21. package/dist/chunk-KBM6CWG4.min.js +2 -0
  22. package/dist/chunk-KBM6CWG4.min.js.map +7 -0
  23. package/dist/chunk-KL7TNUIU.min.js +2 -0
  24. package/dist/chunk-KL7TNUIU.min.js.map +7 -0
  25. package/dist/chunk-L6XOF7P4.min.js +2 -0
  26. package/dist/chunk-L6XOF7P4.min.js.map +7 -0
  27. package/dist/chunk-M7UEET5O.js +1323 -0
  28. package/dist/chunk-M7UEET5O.js.map +7 -0
  29. package/dist/chunk-MH7L756Y.min.js +2 -0
  30. package/dist/chunk-MH7L756Y.min.js.map +7 -0
  31. package/dist/chunk-O3SKPRTY.min.js +2 -0
  32. package/dist/chunk-O3SKPRTY.min.js.map +7 -0
  33. package/dist/chunk-RI7T5VFD.min.js +2 -0
  34. package/dist/chunk-RI7T5VFD.min.js.map +7 -0
  35. package/dist/chunk-RN6QIBWL.min.js +2 -0
  36. package/dist/chunk-RN6QIBWL.min.js.map +7 -0
  37. package/dist/chunk-VKCFJ4OT.min.js +2 -0
  38. package/dist/chunk-VKCFJ4OT.min.js.map +7 -0
  39. package/dist/chunk-VMTTYB4L.min.js +2 -0
  40. package/dist/chunk-VMTTYB4L.min.js.map +7 -0
  41. package/dist/chunk-VP4WLF5A.js +1323 -0
  42. package/dist/chunk-VP4WLF5A.js.map +7 -0
  43. package/dist/chunk-YA3W4XKH.js +1323 -0
  44. package/dist/chunk-YA3W4XKH.js.map +7 -0
  45. package/dist/index.js +213 -2788
  46. package/dist/index.js.map +4 -4
  47. package/dist/index.min.js +6 -6
  48. package/dist/index.min.js.map +4 -4
  49. package/dist/jsx-dev-runtime.js +4 -53
  50. package/dist/jsx-dev-runtime.js.map +3 -3
  51. package/dist/jsx-dev-runtime.min.js +1 -1
  52. package/dist/jsx-dev-runtime.min.js.map +4 -4
  53. package/dist/jsx-runtime.js +4 -53
  54. package/dist/jsx-runtime.js.map +3 -3
  55. package/dist/jsx-runtime.min.js +1 -1
  56. package/dist/jsx-runtime.min.js.map +4 -4
  57. package/dist/render.js +34 -2044
  58. package/dist/render.js.map +4 -4
  59. package/dist/render.min.js +1 -1
  60. package/dist/render.min.js.map +4 -4
  61. package/dist/testing.js +13 -1079
  62. package/dist/testing.js.map +4 -4
  63. package/dist/testing.min.js +1 -1
  64. package/dist/testing.min.js.map +4 -4
  65. package/package.json +2 -2
  66. package/render.d.ts +18 -0
  67. package/src/agent-context.js +3 -2
  68. package/src/dom.js +70 -6
  69. package/src/guardrails.js +17 -46
  70. package/src/h.js +15 -3
  71. package/src/head.js +72 -2
  72. package/src/hooks.js +65 -4
  73. package/src/hydration-data.js +34 -0
  74. package/src/index.js +9 -2
  75. package/src/reactive.js +100 -1
  76. package/src/render.js +604 -155
  77. package/src/server-context.js +48 -0
  78. package/src/store.js +6 -2
package/src/render.js CHANGED
@@ -2,9 +2,13 @@
2
2
  // Solid-style rendering: components run once, signals create individual DOM effects.
3
3
  // No VDOM diffing — direct DOM manipulation with surgical signal-driven updates.
4
4
 
5
- import { effect, untrack, createRoot, _createItemScope, signal, __DEV__ } from './reactive.js';
6
- import { createDOM, disposeTree, getCurrentComponent, getComponentStack } from './dom.js';
5
+ import { effect, untrack, createRoot, _createItemScope, signal, memo, __DEV__ } from './reactive.js';
6
+ import { createDOM, disposeTree, getCurrentComponent, getComponentStack, _setSelectValue } from './dom.js';
7
7
  export { effect, untrack };
8
+ // Re-export memo for compiled output (branch memoization: the compiler emits
9
+ // _$memo(() => cond) so conditional branches only re-create DOM when the
10
+ // condition value actually changes, not on every dependency write).
11
+ export { memo };
8
12
 
9
13
  // --- Generic text insertion hook ---
10
14
  // External text engines (e.g., what-text) register a callback here via
@@ -167,40 +171,50 @@ export function svgTemplate(html) {
167
171
  // - array → insert each element
168
172
 
169
173
  export function insert(parent, child, marker) {
174
+ // mapArray inserter: self-managing reactive list with its own effect
175
+ if (typeof child === 'function' && child._mapArray) {
176
+ return child(parent, marker || null);
177
+ }
178
+
170
179
  if (typeof child === 'function') {
171
- // Fast path: if the first evaluation returns a string/number, optimistically
172
- // create a text node for direct updates. If the value type changes later
173
- // (e.g., text -> vnode), fall back to full reconcileInsert.
174
- const first = child();
175
- const t = typeof first;
176
- if (t === 'string' || t === 'number') {
177
- const textNode = document.createTextNode(String(first));
178
- const m = marker || null;
179
- if (m) parent.insertBefore(textNode, m);
180
- else parent.appendChild(textNode);
181
- if (_onTextInsert) _onTextInsert(parent, String(first));
182
- let current = textNode;
183
- let isTextFastPath = true;
184
- effect(() => {
185
- const val = child();
186
- const vt = typeof val;
187
- if (isTextFastPath && (vt === 'string' || vt === 'number')) {
188
- // Fast path: still text update data directly (no allocations)
189
- const str = String(val);
190
- if (textNode.data !== str) textNode.data = str;
191
- if (_onTextInsert) _onTextInsert(parent, str);
180
+ // Single-evaluation mount: child() is evaluated exactly ONCE at mount,
181
+ // inside the effect (so signal reads are tracked). The first run decides
182
+ // between the text fast path (direct textNode.data updates, zero
183
+ // allocations) and the general reconcile path. Previously the first
184
+ // evaluation happened outside the effect to pick the path, then the
185
+ // effect's first run re-evaluated child() — creating components twice
186
+ // on mount for non-text children. (SPRINT v0.11 C3)
187
+ const m = marker || null;
188
+ let current = null;
189
+ let textNode = null; // non-null while on the text fast path
190
+ let mounted = false;
191
+ effect(() => {
192
+ const val = child();
193
+ const vt = typeof val;
194
+ if (!mounted) {
195
+ // First run mount
196
+ mounted = true;
197
+ if (vt === 'string' || vt === 'number') {
198
+ textNode = document.createTextNode(String(val));
199
+ if (m) parent.insertBefore(textNode, m);
200
+ else parent.appendChild(textNode);
201
+ if (_onTextInsert) _onTextInsert(parent, String(val));
202
+ current = textNode;
192
203
  } else {
193
- // Type changed fall back to full reconcile
194
- isTextFastPath = false;
195
- current = reconcileInsert(parent, val, current, m);
204
+ current = reconcileInsert(parent, val, null, m);
196
205
  }
197
- });
198
- return textNode;
199
- }
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;
202
- effect(() => {
203
- current = reconcileInsert(parent, child(), current, marker || null);
206
+ return;
207
+ }
208
+ if (textNode !== null && (vt === 'string' || vt === 'number')) {
209
+ // Fast path: still text update data directly (no allocations)
210
+ const str = String(val);
211
+ if (textNode.data !== str) textNode.data = str;
212
+ if (_onTextInsert) _onTextInsert(parent, str);
213
+ return;
214
+ }
215
+ // Type changed (or never was text) — full reconcile
216
+ textNode = null;
217
+ current = reconcileInsert(parent, val, current, m);
204
218
  });
205
219
  return current;
206
220
  }
@@ -256,13 +270,28 @@ function valuesToNodes(value, parent, out) {
256
270
  return out;
257
271
  }
258
272
 
273
+ // Resolve function values (reactive accessors passed through props)
274
+ if (typeof value === 'function') {
275
+ valuesToNodes(value(), parent, out);
276
+ return out;
277
+ }
278
+
259
279
  if (typeof value === 'string' || typeof value === 'number') {
260
280
  out.push(document.createTextNode(String(value)));
261
281
  return out;
262
282
  }
263
283
 
264
284
  if (isDomNode(value)) {
265
- out.push(value);
285
+ // DocumentFragments lose their children on DOM insertion, making them
286
+ // untrackable for reconciliation. Flatten to child nodes instead.
287
+ if (value.nodeType === 11 && value.childNodes.length > 0) {
288
+ const children = Array.from(value.childNodes);
289
+ for (let i = 0; i < children.length; i++) {
290
+ out.push(children[i]);
291
+ }
292
+ } else {
293
+ out.push(value);
294
+ }
266
295
  return out;
267
296
  }
268
297
 
@@ -383,7 +412,7 @@ export function mapArray(source, mapFn, options) {
383
412
  const keyFn = options?.key;
384
413
  const raw = options?.raw || false;
385
414
 
386
- return (parent, marker) => {
415
+ const inserter = (parent, marker) => {
387
416
  let items = [];
388
417
  let mappedNodes = [];
389
418
  let disposeFns = [];
@@ -395,10 +424,17 @@ export function mapArray(source, mapFn, options) {
395
424
 
396
425
  effect(() => {
397
426
  const newItems = source() || [];
427
+ // Resolve the LIVE parent from the end marker each run. When this inserter
428
+ // is mounted at a fragment-as-root (`<>{items().map(...)}</>`), createDOM
429
+ // calls it against a throwaway DocumentFragment which is then appended to
430
+ // the real container — the marker (and existing rows) move with it, so the
431
+ // captured `parent` goes stale. endMarker.parentNode always reflects where
432
+ // the list currently lives. Falls back to the captured parent pre-mount.
433
+ const liveParent = endMarker.parentNode || parent;
398
434
  if (keyFn) {
399
- reconcileKeyed(parent, endMarker, items, newItems, mappedNodes, disposeFns, mapFn, keyFn, keyedState);
435
+ reconcileKeyed(liveParent, endMarker, items, newItems, mappedNodes, disposeFns, mapFn, keyFn, keyedState);
400
436
  } else {
401
- reconcileList(parent, endMarker, items, newItems, mappedNodes, disposeFns, mapFn);
437
+ reconcileList(liveParent, endMarker, items, newItems, mappedNodes, disposeFns, mapFn);
402
438
  }
403
439
  // Save a snapshot of items for next diff. Use slice() to defend against
404
440
  // in-place mutation, but skip for empty arrays (common clear case).
@@ -407,6 +443,8 @@ export function mapArray(source, mapFn, options) {
407
443
 
408
444
  return endMarker;
409
445
  };
446
+ inserter._mapArray = true;
447
+ return inserter;
410
448
  }
411
449
 
412
450
  function reconcileList(parent, endMarker, oldItems, newItems, mappedNodes, disposeFns, mapFn) {
@@ -424,10 +462,10 @@ function reconcileList(parent, endMarker, oldItems, newItems, mappedNodes, dispo
424
462
  for (let i = oldLen - 1; i >= 0; i--) {
425
463
  const node = mappedNodes[i];
426
464
  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
- }
465
+ // disposeTree walks the subtree for nested component contexts
466
+ // (c:start comments) and reactive bindings that the item-scope
467
+ // dispose above does not cover. (AUDIT C5)
468
+ disposeTree(node);
431
469
  if (node.parentNode === parent) parent.removeChild(node);
432
470
  }
433
471
  }
@@ -490,6 +528,7 @@ function reconcileList(parent, endMarker, oldItems, newItems, mappedNodes, dispo
490
528
  // Only removals in the middle
491
529
  for (let i = start; i <= oldEnd; i++) {
492
530
  disposeFns[i]?.();
531
+ if (mappedNodes[i]) disposeTree(mappedNodes[i]); // dispose nested component ctx (AUDIT C5)
493
532
  if (mappedNodes[i]?.parentNode) mappedNodes[i].parentNode.removeChild(mappedNodes[i]);
494
533
  }
495
534
  } else if (midOldLen === 0) {
@@ -547,6 +586,7 @@ function _reconcileMiddle(parent, endMarker, oldItems, newItems, mappedNodes, di
547
586
  // Dispose removed items
548
587
  for (const [, oldIdx] of oldIdxMap) {
549
588
  disposeFns[oldIdx]?.();
589
+ if (mappedNodes[oldIdx]) disposeTree(mappedNodes[oldIdx]); // dispose nested component ctx (AUDIT C5)
550
590
  if (mappedNodes[oldIdx]?.parentNode) mappedNodes[oldIdx].parentNode.removeChild(mappedNodes[oldIdx]);
551
591
  }
552
592
 
@@ -655,6 +695,73 @@ function _lis(arr, len) {
655
695
  // When a key persists but its item reference changes, the item signal updates
656
696
  // in place — no DOM node destruction/creation. Only effects reading the
657
697
  // item accessor re-run (e.g., textContent update for changed label).
698
+ //
699
+ // Multi-node items: Components return DocumentFragments (c:start, content, c:end).
700
+ // We track each item via a start-marker comment. Moving/removing an item moves
701
+ // all nodes from its marker up to (but not including) the next item's marker.
702
+
703
+ function _createItemMarker() {
704
+ return document.createComment('i');
705
+ }
706
+
707
+ // Collect all DOM nodes belonging to one item (from its marker to beforeEnd).
708
+ function _collectItemNodes(marker, beforeEnd) {
709
+ const nodes = [];
710
+ let n = marker;
711
+ while (n && n !== beforeEnd) {
712
+ nodes.push(n);
713
+ n = n.nextSibling;
714
+ }
715
+ return nodes;
716
+ }
717
+
718
+ // Move all nodes for an item (starting at marker) before `ref` in `parent`.
719
+ function _moveItem(parent, marker, beforeEnd, ref) {
720
+ let n = marker;
721
+ while (n && n !== beforeEnd) {
722
+ const next = n.nextSibling;
723
+ parent.insertBefore(n, ref);
724
+ n = next;
725
+ }
726
+ }
727
+
728
+ // Remove all nodes for an item from the DOM.
729
+ function _removeItemNodes(parent, marker, beforeEnd) {
730
+ let n = marker;
731
+ while (n && n !== beforeEnd) {
732
+ const next = n.nextSibling;
733
+ // Always disposeTree: a component's context lives on its `c:start` comment
734
+ // (nodeType 8, via _commentCtxMap) which carries none of the gate flags
735
+ // below, so the old `_componentCtx || _dispose || _propEffects` guard
736
+ // leaked every component's effects/cleanups/onCleanup/listeners on removal.
737
+ // disposeTree is internally cheap-guarded and idempotent. (AUDIT C5)
738
+ disposeTree(n);
739
+ parent.removeChild(n);
740
+ n = next;
741
+ }
742
+ }
743
+
744
+ // Create a new item: wraps mapFn result in a marker + appends to target.
745
+ function _createKeyedItem(target, item, idx, keyFn, keyedState, mapFn, mappedArr, disposeArr, signal_) {
746
+ let accessor;
747
+ if (keyedState) {
748
+ const key = keyFn(item);
749
+ const itemSig = signal_(item);
750
+ accessor = itemSig;
751
+ keyedState.set(key, { itemSig });
752
+ } else {
753
+ accessor = item;
754
+ }
755
+ const marker = _createItemMarker();
756
+ target.appendChild(marker);
757
+ const result = _createItemScope(dispose => {
758
+ disposeArr[idx] = dispose;
759
+ return mapFn(accessor, idx);
760
+ });
761
+ // result may be a DocumentFragment or a single node
762
+ target.appendChild(result);
763
+ mappedArr[idx] = marker;
764
+ }
658
765
 
659
766
  function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disposeFns, mapFn, keyFn, keyedState) {
660
767
  const newLen = newItems.length;
@@ -663,18 +770,12 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
663
770
  // --- Fast path: clear all ---
664
771
  if (newLen === 0) {
665
772
  if (oldLen > 0) {
666
- // Dispose reactive scopes first, then remove DOM nodes.
667
773
  for (let i = 0; i < oldLen; i++) {
668
774
  if (disposeFns[i]) disposeFns[i]();
669
775
  }
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
- }
776
+ // Remove all nodes between first item marker and endMarker
777
+ if (mappedNodes[0]) {
778
+ _removeItemNodes(parent, mappedNodes[0], endMarker);
678
779
  }
679
780
  mappedNodes.length = 0;
680
781
  disposeFns.length = 0;
@@ -687,23 +788,7 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
687
788
  if (oldLen === 0) {
688
789
  const frag = document.createDocumentFragment();
689
790
  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);
791
+ _createKeyedItem(frag, newItems[i], i, keyFn, keyedState, mapFn, mappedNodes, disposeFns, signal);
707
792
  }
708
793
  parent.insertBefore(frag, endMarker);
709
794
  return;
@@ -713,12 +798,10 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
713
798
  let start = 0;
714
799
  const minLen = Math.min(oldLen, newLen);
715
800
  while (start < minLen) {
716
- // Fast path: same reference → same key, no update needed
717
801
  if (oldItems[start] === newItems[start]) { start++; continue; }
718
802
  const oldKey = keyFn(oldItems[start]);
719
803
  const newKey = keyFn(newItems[start]);
720
804
  if (oldKey !== newKey) break;
721
- // Key matches but reference changed — update signal (non-raw mode only)
722
805
  if (keyedState) keyedState.get(oldKey).itemSig.set(newItems[start]);
723
806
  start++;
724
807
  }
@@ -736,13 +819,8 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
736
819
  newEnd--;
737
820
  }
738
821
 
739
- // If everything matched, nothing to do
740
- if (start > oldEnd && start > newEnd) {
741
- // Just copy existing mappings to output
742
- return;
743
- }
822
+ if (start > oldEnd && start > newEnd) return;
744
823
 
745
- // Copy prefix/suffix into output arrays
746
824
  const newMapped = new Array(newLen);
747
825
  const newDispose = new Array(newLen);
748
826
  for (let i = 0; i < start; i++) {
@@ -760,27 +838,12 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
760
838
 
761
839
  // --- Only additions in middle ---
762
840
  if (midOldLen === 0) {
763
- const marker = newEnd + 1 < newLen && newMapped[newEnd + 1] ? newMapped[newEnd + 1] : endMarker;
841
+ const ref = newEnd + 1 < newLen && newMapped[newEnd + 1] ? newMapped[newEnd + 1] : endMarker;
764
842
  const frag = document.createDocumentFragment();
765
843
  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]);
844
+ _createKeyedItem(frag, newItems[i], i, keyFn, keyedState, mapFn, newMapped, newDispose, signal);
782
845
  }
783
- parent.insertBefore(frag, marker);
846
+ parent.insertBefore(frag, ref);
784
847
  _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen);
785
848
  return;
786
849
  }
@@ -789,15 +852,243 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
789
852
  if (midNewLen === 0) {
790
853
  for (let i = start; i <= oldEnd; i++) {
791
854
  disposeFns[i]?.();
792
- if (mappedNodes[i]?.parentNode) parent.removeChild(mappedNodes[i]);
855
+ // Compute the range boundary from the live DOM. Sibling markers in
856
+ // mappedNodes may have been detached by earlier iterations of this loop;
857
+ // walking the DOM finds the next surviving item marker (or endMarker).
858
+ const rangeEnd = _findNextMarkerAfter(parent, mappedNodes[i], mappedNodes, i, endMarker);
859
+ _removeItemNodes(parent, mappedNodes[i], rangeEnd);
793
860
  if (keyedState) keyedState.delete(keyFn(oldItems[i]));
794
861
  }
795
862
  _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen);
796
863
  return;
797
864
  }
798
865
 
866
+ // --- Fast paths for common small-move cases ---
867
+ // Detect swap (2 mismatches) or single-move (contiguous shift) cheaply
868
+ // before falling through to the expensive LIS + backward-walk general case.
869
+
870
+ if (midNewLen === midOldLen && midNewLen >= 2 && midNewLen <= Math.max(midOldLen, 200)) {
871
+ // Count positions where keys differ
872
+ let mismatchCount = 0;
873
+ let mm1 = -1, mm2 = -1; // first two mismatch indices (relative to start)
874
+ for (let i = 0; i < midNewLen && mismatchCount <= 4; i++) {
875
+ const oldKey = keyFn(oldItems[start + i]);
876
+ const newKey = keyFn(newItems[start + i]);
877
+ if (oldKey !== newKey) {
878
+ if (mismatchCount === 0) mm1 = i;
879
+ else if (mismatchCount === 1) mm2 = i;
880
+ mismatchCount++;
881
+ }
882
+ }
883
+
884
+ // --- Fast path A: Pure swap (exactly 2 key mismatches, keys exchanged) ---
885
+ if (mismatchCount === 2) {
886
+ const i1 = start + mm1, i2 = start + mm2;
887
+ const oldKey1 = keyFn(oldItems[i1]), oldKey2 = keyFn(oldItems[i2]);
888
+ const newKey1 = keyFn(newItems[i1]), newKey2 = keyFn(newItems[i2]);
889
+
890
+ if (oldKey1 === newKey2 && oldKey2 === newKey1) {
891
+ // Confirmed swap. Move item at i2's DOM position before item at i1's position,
892
+ // then move i1's nodes to where i2 was.
893
+ for (let i = 0; i < start; i++) {
894
+ newMapped[i] = mappedNodes[i];
895
+ newDispose[i] = disposeFns[i];
896
+ }
897
+ for (let i = start; i <= newEnd; i++) {
898
+ newMapped[i] = mappedNodes[i];
899
+ newDispose[i] = disposeFns[i];
900
+ }
901
+ for (let i = newEnd + 1; i < newLen; i++) {
902
+ const oldI = oldEnd + 1 + (i - newEnd - 1);
903
+ newMapped[i] = mappedNodes[oldI];
904
+ newDispose[i] = disposeFns[oldI];
905
+ }
906
+
907
+ // Swap mapped entries
908
+ const tmpM = newMapped[i1]; newMapped[i1] = newMapped[i2]; newMapped[i2] = tmpM;
909
+ const tmpD = newDispose[i1]; newDispose[i1] = newDispose[i2]; newDispose[i2] = tmpD;
910
+
911
+ // Update keyed state signals if item references differ
912
+ if (keyedState) {
913
+ if (newItems[i1] !== oldItems[i1]) {
914
+ const k = keyFn(newItems[i1]);
915
+ const entry = keyedState.get(k);
916
+ if (entry) entry.itemSig.set(newItems[i1]);
917
+ }
918
+ if (newItems[i2] !== oldItems[i2]) {
919
+ const k = keyFn(newItems[i2]);
920
+ const entry = keyedState.get(k);
921
+ if (entry) entry.itemSig.set(newItems[i2]);
922
+ }
923
+ }
924
+
925
+ // DOM moves: swap the two items' DOM ranges.
926
+ // Adjacent swaps need special handling because moving item2 before
927
+ // item1 invalidates the pre-computed end boundary for item1 (it was
928
+ // item2's marker, which has now moved). For adjacent items, a single
929
+ // _moveItem suffices. For non-adjacent items, we recompute end1 after
930
+ // the first move.
931
+ const isAdjacent = (i2 === i1 + 1) || (i1 === i2 + 1);
932
+ const lo = Math.min(i1, i2), hi = Math.max(i1, i2);
933
+
934
+ if (isAdjacent) {
935
+ // Adjacent: just move the later item's nodes before the earlier item's marker.
936
+ const endHi = _findNextMarkerAfter(parent, mappedNodes[hi], mappedNodes, hi, endMarker);
937
+ _moveItem(parent, mappedNodes[hi], endHi, mappedNodes[lo]);
938
+ } else {
939
+ // Non-adjacent: use a placeholder to remember i2's position, then
940
+ // recompute end1 after the first move (since DOM has changed).
941
+ const end2 = _findNextMarkerAfter(parent, mappedNodes[i2], mappedNodes, i2, endMarker);
942
+
943
+ const placeholder = document.createComment('tmp');
944
+ parent.insertBefore(placeholder, mappedNodes[i2]);
945
+
946
+ // Move i2's nodes to before i1's current position
947
+ _moveItem(parent, mappedNodes[i2], end2, mappedNodes[i1]);
948
+ // Recompute end1 — the DOM has changed, so the pre-move boundary is stale
949
+ const end1 = _findNextMarkerAfter(parent, mappedNodes[i1], mappedNodes, i1, endMarker);
950
+ // Move i1's nodes to where i2 was (before placeholder)
951
+ _moveItem(parent, mappedNodes[i1], end1, placeholder);
952
+ parent.removeChild(placeholder);
953
+ }
954
+
955
+ _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen);
956
+ return;
957
+ }
958
+ }
959
+
960
+ // --- Fast path B: Single item relocated ---
961
+ // One item removed from position `from` and inserted at position `to`,
962
+ // everything between shifted by one.
963
+ if (mismatchCount >= 2 && mismatchCount <= midNewLen) {
964
+ // Try to detect single-move pattern:
965
+ // If we remove element at `from` in old and insert at `to` in new,
966
+ // the rest should match.
967
+ // Forward move: old[from] = new[to], old[from+1..to] = new[from..to-1]
968
+ // Backward move: old[from] = new[to], old[to..from-1] = new[to+1..from]
969
+
970
+ const fromRel = mm1; // first mismatch - the moved item was here in old OR went here in new
971
+ let movedKey = null;
972
+ let fromAbs = -1, toAbs = -1;
973
+ let isMove = false;
974
+
975
+ // Check forward move: item at old[start+fromRel] moved later
976
+ const candidateKey = keyFn(oldItems[start + fromRel]);
977
+ // Find where this key ended up in new
978
+ let destRel = -1;
979
+ for (let i = fromRel; i < midNewLen; i++) {
980
+ if (keyFn(newItems[start + i]) === candidateKey) { destRel = i; break; }
981
+ }
982
+ if (destRel > fromRel) {
983
+ // Verify: old[fromRel+1..destRel] should match new[fromRel..destRel-1]
984
+ let match = true;
985
+ for (let i = fromRel; i < destRel; i++) {
986
+ if (keyFn(oldItems[start + i + 1]) !== keyFn(newItems[start + i])) { match = false; break; }
987
+ }
988
+ if (match) {
989
+ // And everything after destRel should be the same
990
+ let afterMatch = true;
991
+ for (let i = destRel + 1; i < midNewLen; i++) {
992
+ if (keyFn(oldItems[start + i]) !== keyFn(newItems[start + i])) { afterMatch = false; break; }
993
+ }
994
+ if (afterMatch) {
995
+ isMove = true;
996
+ fromAbs = start + fromRel;
997
+ toAbs = start + destRel;
998
+ movedKey = candidateKey;
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ if (!isMove) {
1004
+ // Check backward move: item from later in old moved to start+fromRel in new
1005
+ const candidateKey2 = keyFn(newItems[start + fromRel]);
1006
+ let srcRel = -1;
1007
+ for (let i = fromRel; i < midOldLen; i++) {
1008
+ if (keyFn(oldItems[start + i]) === candidateKey2) { srcRel = i; break; }
1009
+ }
1010
+ if (srcRel > fromRel) {
1011
+ // Verify: old[fromRel..srcRel-1] should match new[fromRel+1..srcRel]
1012
+ let match = true;
1013
+ for (let i = fromRel; i < srcRel; i++) {
1014
+ if (keyFn(oldItems[start + i]) !== keyFn(newItems[start + i + 1])) { match = false; break; }
1015
+ }
1016
+ if (match) {
1017
+ let afterMatch = true;
1018
+ for (let i = srcRel + 1; i < midNewLen; i++) {
1019
+ if (keyFn(oldItems[start + i]) !== keyFn(newItems[start + i])) { afterMatch = false; break; }
1020
+ }
1021
+ if (afterMatch) {
1022
+ isMove = true;
1023
+ fromAbs = start + srcRel;
1024
+ toAbs = start + fromRel;
1025
+ movedKey = candidateKey2;
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ if (isMove) {
1032
+ // Copy all mapped/dispose to new arrays
1033
+ for (let i = start; i <= oldEnd; i++) {
1034
+ newMapped[i] = mappedNodes[i];
1035
+ newDispose[i] = disposeFns[i];
1036
+ }
1037
+
1038
+ // Shift entries in newMapped/newDispose to reflect the move
1039
+ const movedMarker = newMapped[fromAbs];
1040
+ const movedDispose = newDispose[fromAbs];
1041
+
1042
+ if (fromAbs < toAbs) {
1043
+ // Forward move: shift [from+1..to] left by 1
1044
+ for (let i = fromAbs; i < toAbs; i++) {
1045
+ newMapped[i] = newMapped[i + 1];
1046
+ newDispose[i] = newDispose[i + 1];
1047
+ }
1048
+ } else {
1049
+ // Backward move: shift [to..from-1] right by 1
1050
+ for (let i = fromAbs; i > toAbs; i--) {
1051
+ newMapped[i] = newMapped[i - 1];
1052
+ newDispose[i] = newDispose[i - 1];
1053
+ }
1054
+ }
1055
+ newMapped[toAbs] = movedMarker;
1056
+ newDispose[toAbs] = movedDispose;
1057
+
1058
+ // Update keyed state signals for items whose references changed
1059
+ if (keyedState) {
1060
+ for (let i = start; i <= newEnd; i++) {
1061
+ const key = keyFn(newItems[i]);
1062
+ if (newItems[i] !== oldItems[i]) {
1063
+ // Only look up oldItems[i] by key if index is in old range
1064
+ const entry = keyedState.get(key);
1065
+ if (entry) entry.itemSig.set(newItems[i]);
1066
+ }
1067
+ }
1068
+ }
1069
+
1070
+ // Single DOM move: move the item's nodes to its new position
1071
+ const movedEnd = _findNextMarkerAfter(parent, movedMarker, mappedNodes, fromAbs, endMarker);
1072
+ // Find the reference node: the marker of the item that should come AFTER the moved item
1073
+ let ref;
1074
+ if (toAbs + 1 < newLen) {
1075
+ ref = newMapped[toAbs + 1];
1076
+ } else {
1077
+ ref = endMarker;
1078
+ }
1079
+ // For suffix items, use the actual mapped marker
1080
+ if (toAbs >= newEnd + 1 || (ref && ref.parentNode !== parent)) {
1081
+ ref = endMarker;
1082
+ }
1083
+ _moveItem(parent, movedMarker, movedEnd, ref);
1084
+
1085
+ _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen);
1086
+ return;
1087
+ }
1088
+ }
1089
+ }
1090
+
799
1091
  // --- General case: reconcile middle section ---
800
- // Build old key → old index map for middle section only
801
1092
  const oldKeyMap = new Map();
802
1093
  for (let i = start; i <= oldEnd; i++) {
803
1094
  oldKeyMap.set(keyFn(oldItems[i]), i);
@@ -806,7 +1097,6 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
806
1097
  const oldIndices = new Int32Array(midNewLen);
807
1098
  oldIndices.fill(-1);
808
1099
 
809
- // Match by key
810
1100
  for (let i = start; i <= newEnd; i++) {
811
1101
  const key = keyFn(newItems[i]);
812
1102
  const oldIdx = oldKeyMap.get(key);
@@ -815,43 +1105,35 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
815
1105
  newMapped[i] = mappedNodes[oldIdx];
816
1106
  newDispose[i] = disposeFns[oldIdx];
817
1107
  oldIndices[i - start] = oldIdx;
818
- // Update item signal if reference changed (non-raw mode only)
819
1108
  if (keyedState && newItems[i] !== oldItems[oldIdx]) {
820
1109
  keyedState.get(key).itemSig.set(newItems[i]);
821
1110
  }
822
1111
  }
823
1112
  }
824
1113
 
825
- // Dispose removed items
826
- for (const [key, oldIdx] of oldKeyMap) {
1114
+ // Dispose removed items (iterate in reverse to avoid shifting boundaries)
1115
+ const removedIndices = [...oldKeyMap.values()].sort((a, b) => b - a);
1116
+ for (const oldIdx of removedIndices) {
827
1117
  disposeFns[oldIdx]?.();
828
- if (mappedNodes[oldIdx]?.parentNode) parent.removeChild(mappedNodes[oldIdx]);
829
- if (keyedState) keyedState.delete(key);
1118
+ // Compute the range boundary from the live DOM. Adjacent removals can
1119
+ // detach mappedNodes[oldIdx + 1] before we get here, so we cannot trust
1120
+ // that reference — walk the DOM to find the next surviving item marker.
1121
+ const rangeEnd = _findNextMarkerAfter(parent, mappedNodes[oldIdx], mappedNodes, oldIdx, endMarker);
1122
+ _removeItemNodes(parent, mappedNodes[oldIdx], rangeEnd);
1123
+ if (keyedState) keyedState.delete(keyFn(oldItems[oldIdx]));
830
1124
  }
831
1125
 
832
- // Create new items
1126
+ // Create new items (into a detached fragment, then positioned below)
833
1127
  for (let i = start; i <= newEnd; i++) {
834
1128
  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
- });
1129
+ const frag = document.createDocumentFragment();
1130
+ _createKeyedItem(frag, newItems[i], i, keyFn, keyedState, mapFn, newMapped, newDispose, signal);
1131
+ // Leave in frag for now — will be positioned in the move pass
1132
+ newMapped[i]._frag = frag;
850
1133
  }
851
1134
  }
852
1135
 
853
1136
  // Position using LIS
854
- // First check: are reused items already in order? (common for update-in-place)
855
1137
  let reusedCount = 0;
856
1138
  let alreadySorted = true;
857
1139
  let lastOldIdx = -1;
@@ -866,7 +1148,6 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
866
1148
  const inLIS = new Uint8Array(midNewLen);
867
1149
 
868
1150
  if (alreadySorted) {
869
- // All reused items are in order — mark all as in LIS (no moves needed)
870
1151
  for (let i = 0; i < midNewLen; i++) {
871
1152
  if (oldIndices[i] !== -1) inLIS[i] = 1;
872
1153
  }
@@ -891,21 +1172,48 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
891
1172
  }
892
1173
  }
893
1174
 
894
- // Position: work backwards, insert items not in LIS
895
- let nextSibling = newEnd + 1 < newMapped.length && newMapped[newEnd + 1]
896
- ? newMapped[newEnd + 1] : endMarker;
1175
+ // Position: work backwards, move items not in LIS
1176
+ // For existing items: move all nodes from marker to next-item boundary.
1177
+ // For new items: insert from their detached fragment.
1178
+ // We rebuild the output array to reflect final positions.
1179
+ _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen);
897
1180
 
1181
+ // Start ref at the first suffix item's marker (not endMarker) so moved items
1182
+ // land before the suffix, not after it.
1183
+ let ref = newEnd + 1 < newLen && mappedNodes[newEnd + 1]
1184
+ ? mappedNodes[newEnd + 1] : endMarker;
898
1185
  for (let i = newEnd; i >= start; i--) {
899
1186
  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);
1187
+ const marker = mappedNodes[i];
1188
+
1189
+ if (oldIndices[mi] === -1) {
1190
+ // New item — insert from detached fragment
1191
+ if (marker._frag) {
1192
+ parent.insertBefore(marker._frag, ref);
1193
+ delete marker._frag;
1194
+ }
1195
+ } else if (!inLIS[mi]) {
1196
+ // Existing item not in LIS — move all its nodes
1197
+ const nextItemMarker = _findNextMarkerAfter(parent, marker, mappedNodes, i, endMarker);
1198
+ _moveItem(parent, marker, nextItemMarker, ref);
904
1199
  }
905
- nextSibling = newMapped[i];
1200
+ ref = marker;
906
1201
  }
1202
+ }
907
1203
 
908
- _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen);
1204
+ // TODO(perf): cache item end boundary on marker if large keyed reorders show O(n²) hot paths.
1205
+ // Find the boundary end for an item's nodes in the current DOM.
1206
+ // Walks from the marker's nextSibling until we hit another item's marker or endMarker.
1207
+ function _findNextMarkerAfter(parent, marker, mappedNodes, idx, endMarker) {
1208
+ // The item's nodes end at the next sibling that is either:
1209
+ // - another item's marker comment (data === 'i')
1210
+ // - the list endMarker (data === '/list')
1211
+ let n = marker.nextSibling;
1212
+ while (n && n !== endMarker) {
1213
+ if (n.nodeType === 8 && n.data === 'i') return n;
1214
+ n = n.nextSibling;
1215
+ }
1216
+ return endMarker;
909
1217
  }
910
1218
 
911
1219
  function _copyBack(mappedNodes, disposeFns, newMapped, newDispose, newLen) {
@@ -933,18 +1241,28 @@ export function spread(el, props) {
933
1241
  }
934
1242
 
935
1243
  if (typeof value === 'function' && !key.startsWith('on')) {
936
- // Reactive prop — create micro-effect
1244
+ // Reactive prop — create micro-effect. The disposer must be registered
1245
+ // on el._propEffects so disposeTree() (dom.js) tears it down when the
1246
+ // element unmounts; otherwise the effect keeps firing on signal writes
1247
+ // for a detached element. Mirror the setProp() pattern.
1248
+ if (!el._propEffects) el._propEffects = {};
1249
+ // If a previous spread/setProp already registered an effect for this
1250
+ // key, dispose it first to avoid double-tracking.
1251
+ if (el._propEffects[key]) {
1252
+ try { el._propEffects[key](); } catch (e) { /* already disposed */ }
1253
+ }
937
1254
  if (key === 'class' || key === 'className') {
938
- effect(() => { el.className = value() || ''; });
939
- } else if (key === 'style' && typeof value() === 'object') {
940
- effect(() => {
941
- const styles = value();
942
- for (const prop in styles) {
943
- el.style[prop] = styles[prop] ?? '';
944
- }
1255
+ el._propEffects[key] = effect(() => {
1256
+ const cls = value() || '';
1257
+ if (_hasSVGElement && el instanceof SVGElement) el.setAttribute('class', cls);
1258
+ else el.className = cls;
945
1259
  });
1260
+ } else if (key === 'style' && typeof value() === 'object') {
1261
+ // Route through setStyle so stale object keys are cleared between
1262
+ // re-evaluations (el._lastStyleObj diffing).
1263
+ el._propEffects[key] = effect(() => { setStyle(el, value()); });
946
1264
  } else {
947
- effect(() => { setProp(el, key, value()); });
1265
+ el._propEffects[key] = effect(() => { setProp(el, key, value()); });
948
1266
  }
949
1267
  } else {
950
1268
  // Static prop
@@ -953,6 +1271,15 @@ export function spread(el, props) {
953
1271
  }
954
1272
  }
955
1273
 
1274
+ // NOTE: this is the fine-grained-compiler path's setProp. A second
1275
+ // implementation lives in dom.js (h()/diff path). See the longer note above
1276
+ // the dom.js version. Key differences vs. dom.js setProp:
1277
+ // - assumes events are handled by the compiler (delegation or direct
1278
+ // addEventListener) — no el._events bookkeeping here.
1279
+ // - sanitizes URL attributes (href/src) against javascript: protocol.
1280
+ // - enforces innerHTML must be { __html: ... } — plain strings are warned.
1281
+ // Both share the el._propEffects[key] disposer convention so disposeTree()
1282
+ // can tear down reactive prop effects on unmount.
956
1283
  export function setProp(el, key, value) {
957
1284
  // Ref handling — assign element to ref object/callback (defense in depth)
958
1285
  if (key === 'ref') {
@@ -964,6 +1291,19 @@ export function setProp(el, key, value) {
964
1291
  // Key prop — no-op, WhatFW has no virtual DOM (defense in depth, issue #6)
965
1292
  if (key === 'key') return;
966
1293
 
1294
+ // Reactive accessor: function values on non-event props are treated as
1295
+ // reactive getters. Wrap in an effect so the prop auto-updates. Track the
1296
+ // disposer on el._propEffects so disposeTree() tears it down on unmount —
1297
+ // mirrors the pattern in dom.js setProp / spread().
1298
+ if (typeof value === 'function' && !key.startsWith('on')) {
1299
+ if (!el._propEffects) el._propEffects = {};
1300
+ if (el._propEffects[key]) {
1301
+ try { el._propEffects[key](); } catch (e) { /* already disposed */ }
1302
+ }
1303
+ el._propEffects[key] = effect(() => setProp(el, key, value()));
1304
+ return;
1305
+ }
1306
+
967
1307
  // Sanitize URL attributes — reject dangerous protocols
968
1308
  if (URL_ATTRS.has(key) || URL_ATTRS.has(key.toLowerCase())) {
969
1309
  if (!isSafeUrl(value)) {
@@ -974,35 +1314,46 @@ export function setProp(el, key, value) {
974
1314
  }
975
1315
  }
976
1316
 
1317
+ const isSvg = _hasSVGElement && el instanceof SVGElement;
1318
+
977
1319
  if (key === 'class' || key === 'className') {
978
- el.className = value || '';
1320
+ if (isSvg) {
1321
+ el.setAttribute('class', value || '');
1322
+ } else {
1323
+ el.className = value || '';
1324
+ }
979
1325
  } else if (key === 'dangerouslySetInnerHTML') {
980
- el.innerHTML = value?.__html ?? '';
1326
+ const html = value?.__html ?? '';
1327
+ if (typeof __DEV__ !== 'undefined' && __DEV__ && typeof html === 'string' && /(<script|onerror\s*=|onload\s*=|javascript:)/i.test(html)) {
1328
+ console.warn('[what] dangerouslySetInnerHTML contains potential XSS vectors. Ensure content is sanitized.');
1329
+ }
1330
+ el.innerHTML = html;
981
1331
  } else if (key === 'innerHTML') {
982
1332
  if (value && typeof value === 'object' && '__html' in value) {
983
- el.innerHTML = value.__html ?? '';
1333
+ const html = value.__html ?? '';
1334
+ if (typeof __DEV__ !== 'undefined' && __DEV__ && typeof html === 'string' && /(<script|onerror\s*=|onload\s*=|javascript:)/i.test(html)) {
1335
+ console.warn('[what] dangerouslySetInnerHTML contains potential XSS vectors. Ensure content is sanitized.');
1336
+ }
1337
+ el.innerHTML = html;
984
1338
  } else {
985
- // Plain string innerHTML is rejected for security — use { __html: string } form
986
1339
  if (typeof console !== 'undefined' && value != null && value !== '') {
987
1340
  console.warn(
988
1341
  '[what] Plain string innerHTML is not allowed. Use { __html: "..." } or dangerouslySetInnerHTML={{ __html: "..." }} instead.'
989
1342
  );
990
1343
  }
991
- // Ignored — do not set innerHTML from plain string
992
1344
  }
993
1345
  } else if (key === 'style') {
994
- if (typeof value === 'string') {
995
- el.style.cssText = value;
996
- } else if (typeof value === 'object') {
997
- for (const prop in value) {
998
- el.style[prop] = value[prop] ?? '';
999
- }
1000
- }
1346
+ // Delegate to setStyle so the object form clears stale keys (el._lastStyleObj).
1347
+ setStyle(el, value);
1001
1348
  } else if (key.startsWith('data-') || key.startsWith('aria-')) {
1002
1349
  el.setAttribute(key, value);
1003
1350
  } else if (typeof value === 'boolean') {
1004
1351
  if (value) el.setAttribute(key, '');
1005
1352
  else el.removeAttribute(key);
1353
+ } else if (isSvg) {
1354
+ el.setAttribute(key, value);
1355
+ } else if (key === 'value' && el.tagName === 'SELECT') {
1356
+ _setSelectValue(el, value);
1006
1357
  } else if (key in el) {
1007
1358
  el[key] = value;
1008
1359
  } else {
@@ -1010,6 +1361,99 @@ export function setProp(el, key, value) {
1010
1361
  }
1011
1362
  }
1012
1363
 
1364
+ // --- Specialized attribute setters (SPRINT v0.11 C2) ---
1365
+ // The compiler statically knows most attribute names, so it emits direct calls
1366
+ // to these monomorphic helpers instead of routing everything through the
1367
+ // generic setProp() dispatcher (which re-checks ref/key/url/class/style/...
1368
+ // string-compares on every reactive update). setProp() remains the target for
1369
+ // spreads, URL attributes (href/src/action — sanitization lives there) and any
1370
+ // name the compiler can't classify.
1371
+ //
1372
+ // Function values are reactive ACCESSORS (e.g. `value={() => user().name}`),
1373
+ // exactly like setProp treats them: wrap in an effect that re-applies the
1374
+ // resolved value, with the disposer registered on el._propEffects so
1375
+ // disposeTree() tears it down on unmount.
1376
+
1377
+ function _wrapPropAccessor(el, key, accessor, apply) {
1378
+ if (!el._propEffects) el._propEffects = {};
1379
+ if (el._propEffects[key]) {
1380
+ try { el._propEffects[key](); } catch (e) { /* already disposed */ }
1381
+ }
1382
+ el._propEffects[key] = effect(() => apply(el, accessor()));
1383
+ }
1384
+
1385
+ // class / className — hottest dynamic attribute in real apps.
1386
+ export function setClass(el, value) {
1387
+ if (typeof value === 'function') return _wrapPropAccessor(el, 'class', value, setClass);
1388
+ if (_hasSVGElement && el instanceof SVGElement) {
1389
+ el.setAttribute('class', value || '');
1390
+ } else {
1391
+ el.className = value || '';
1392
+ }
1393
+ }
1394
+
1395
+ // style — string (cssText) or object form.
1396
+ export function setStyle(el, value) {
1397
+ if (typeof value === 'function') return _wrapPropAccessor(el, 'style', value, setStyle);
1398
+ if (typeof value === 'string') {
1399
+ el.style.cssText = value;
1400
+ // cssText fully replaces inline styles — drop any tracked object so a later
1401
+ // object form starts clean rather than diffing against stale keys.
1402
+ el._lastStyleObj = null;
1403
+ } else if (value && typeof value === 'object') {
1404
+ const style = el.style;
1405
+ // Clear properties present in the previously-applied object but absent from
1406
+ // the new one. Without this, `style={() => cond() ? {color, fontWeight} :
1407
+ // {color}}` would leave fontWeight set after flipping to the second object.
1408
+ const prev = el._lastStyleObj;
1409
+ if (prev) {
1410
+ for (const prop in prev) {
1411
+ if (!(prop in value)) style[prop] = '';
1412
+ }
1413
+ }
1414
+ for (const prop in value) {
1415
+ style[prop] = value[prop] ?? '';
1416
+ }
1417
+ el._lastStyleObj = value;
1418
+ } else if (value == null) {
1419
+ el.style.cssText = '';
1420
+ el._lastStyleObj = null;
1421
+ }
1422
+ }
1423
+
1424
+ // Plain attribute set — used for data-*/aria-* (statically recognizable).
1425
+ // null/undefined removes the attribute (previously setProp stringified them
1426
+ // to "null"/"undefined" — removal is the correct semantic). Booleans are
1427
+ // stringified ("true"/"false") because aria-* boolean strings are meaningful.
1428
+ export function setAttr(el, name, value) {
1429
+ if (typeof value === 'function') {
1430
+ return _wrapPropAccessor(el, name, value, (e2, v) => setAttr(e2, name, v));
1431
+ }
1432
+ if (value == null) el.removeAttribute(name);
1433
+ else el.setAttribute(name, value);
1434
+ }
1435
+
1436
+ // value — controlled-input property set. <select> keeps multi/deferred-option
1437
+ // handling; other elements get a guarded property write (the !== guard avoids
1438
+ // resetting the caret position in focused inputs on unrelated re-runs).
1439
+ export function setValue(el, value) {
1440
+ if (typeof value === 'function') return _wrapPropAccessor(el, 'value', value, setValue);
1441
+ if (el.tagName === 'SELECT') {
1442
+ _setSelectValue(el, value);
1443
+ return;
1444
+ }
1445
+ const str = value == null ? '' : String(value);
1446
+ if (el.value !== str) el.value = str;
1447
+ }
1448
+
1449
+ // checked — live property write (matches bind:checked). The old generic path
1450
+ // used setAttribute('checked'), which only sets the DEFAULT-checked state and
1451
+ // stops reflecting once the user has toggled the input.
1452
+ export function setChecked(el, value) {
1453
+ if (typeof value === 'function') return _wrapPropAccessor(el, 'checked', value, setChecked);
1454
+ el.checked = !!value;
1455
+ }
1456
+
1013
1457
  // --- delegateEvents(eventNames) ---
1014
1458
  // Event delegation: common events handled at document level.
1015
1459
  // Handlers stored as el.$$click, el.$$input, etc.
@@ -1026,6 +1470,15 @@ export function delegateEvents(eventNames) {
1026
1470
  let node = e.target;
1027
1471
  const key = '$$' + name;
1028
1472
 
1473
+ // Shim e.currentTarget so handlers see the element the (virtual) listener
1474
+ // is attached to — not `document` — during the ancestor walk. Mirrors
1475
+ // Solid's delegation shim. configurable so nested dispatch can redefine.
1476
+ // (SPRINT v0.11 C9)
1477
+ Object.defineProperty(e, 'currentTarget', {
1478
+ configurable: true,
1479
+ get() { return node || document; },
1480
+ });
1481
+
1029
1482
  // Walk up the DOM tree looking for handlers
1030
1483
  while (node) {
1031
1484
  const handler = node[key];
@@ -1314,12 +1767,8 @@ function hydrateElementProps(el, props) {
1314
1767
  if (key === 'class' || key === 'className') {
1315
1768
  effect(() => { el.className = value() || ''; });
1316
1769
  } else if (key === 'style' && typeof value() === 'object') {
1317
- effect(() => {
1318
- const styles = value();
1319
- for (const prop in styles) {
1320
- el.style[prop] = styles[prop] ?? '';
1321
- }
1322
- });
1770
+ // Route through setStyle so stale object keys are cleared (el._lastStyleObj).
1771
+ effect(() => { setStyle(el, value()); });
1323
1772
  } else {
1324
1773
  effect(() => { setProp(el, key, value()); });
1325
1774
  }