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.
- package/dist/chunk-2IZMPODD.min.js +2 -0
- package/dist/chunk-2IZMPODD.min.js.map +7 -0
- package/dist/chunk-2P7OVL2L.js +1386 -0
- package/dist/chunk-2P7OVL2L.js.map +7 -0
- package/dist/chunk-5EQUBJWQ.js +1365 -0
- package/dist/chunk-5EQUBJWQ.js.map +7 -0
- package/dist/chunk-6DAIK77K.min.js +2 -0
- package/dist/chunk-6DAIK77K.min.js.map +7 -0
- package/dist/chunk-AW3BAPIK.js +1685 -0
- package/dist/chunk-AW3BAPIK.js.map +7 -0
- package/dist/chunk-AZP2EOGX.js +188 -0
- package/dist/chunk-AZP2EOGX.js.map +7 -0
- package/dist/chunk-CCINITLW.js +1692 -0
- package/dist/chunk-CCINITLW.js.map +7 -0
- package/dist/chunk-F2HUXI22.js +1675 -0
- package/dist/chunk-F2HUXI22.js.map +7 -0
- package/dist/chunk-GZRA4IAJ.js +1699 -0
- package/dist/chunk-GZRA4IAJ.js.map +7 -0
- package/dist/chunk-H3GA34JK.js +1384 -0
- package/dist/chunk-H3GA34JK.js.map +7 -0
- package/dist/chunk-KBM6CWG4.min.js +2 -0
- package/dist/chunk-KBM6CWG4.min.js.map +7 -0
- package/dist/chunk-KL7TNUIU.min.js +2 -0
- package/dist/chunk-KL7TNUIU.min.js.map +7 -0
- package/dist/chunk-L6XOF7P4.min.js +2 -0
- package/dist/chunk-L6XOF7P4.min.js.map +7 -0
- package/dist/chunk-M7UEET5O.js +1323 -0
- package/dist/chunk-M7UEET5O.js.map +7 -0
- package/dist/chunk-MH7L756Y.min.js +2 -0
- package/dist/chunk-MH7L756Y.min.js.map +7 -0
- package/dist/chunk-O3SKPRTY.min.js +2 -0
- package/dist/chunk-O3SKPRTY.min.js.map +7 -0
- package/dist/chunk-RI7T5VFD.min.js +2 -0
- package/dist/chunk-RI7T5VFD.min.js.map +7 -0
- package/dist/chunk-RN6QIBWL.min.js +2 -0
- package/dist/chunk-RN6QIBWL.min.js.map +7 -0
- package/dist/chunk-VKCFJ4OT.min.js +2 -0
- package/dist/chunk-VKCFJ4OT.min.js.map +7 -0
- package/dist/chunk-VMTTYB4L.min.js +2 -0
- package/dist/chunk-VMTTYB4L.min.js.map +7 -0
- package/dist/chunk-VP4WLF5A.js +1323 -0
- package/dist/chunk-VP4WLF5A.js.map +7 -0
- package/dist/chunk-YA3W4XKH.js +1323 -0
- package/dist/chunk-YA3W4XKH.js.map +7 -0
- package/dist/index.js +213 -2788
- package/dist/index.js.map +4 -4
- package/dist/index.min.js +6 -6
- package/dist/index.min.js.map +4 -4
- package/dist/jsx-dev-runtime.js +4 -53
- package/dist/jsx-dev-runtime.js.map +3 -3
- package/dist/jsx-dev-runtime.min.js +1 -1
- package/dist/jsx-dev-runtime.min.js.map +4 -4
- package/dist/jsx-runtime.js +4 -53
- package/dist/jsx-runtime.js.map +3 -3
- package/dist/jsx-runtime.min.js +1 -1
- package/dist/jsx-runtime.min.js.map +4 -4
- package/dist/render.js +34 -2044
- package/dist/render.js.map +4 -4
- package/dist/render.min.js +1 -1
- package/dist/render.min.js.map +4 -4
- package/dist/testing.js +13 -1079
- package/dist/testing.js.map +4 -4
- package/dist/testing.min.js +1 -1
- package/dist/testing.min.js.map +4 -4
- package/package.json +2 -2
- package/render.d.ts +18 -0
- package/src/agent-context.js +3 -2
- package/src/dom.js +70 -6
- package/src/guardrails.js +17 -46
- package/src/h.js +15 -3
- package/src/head.js +72 -2
- package/src/hooks.js +65 -4
- package/src/hydration-data.js +34 -0
- package/src/index.js +9 -2
- package/src/reactive.js +100 -1
- package/src/render.js +604 -155
- package/src/server-context.js +48 -0
- 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
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
isTextFastPath = false;
|
|
195
|
-
current = reconcileInsert(parent, val, current, m);
|
|
204
|
+
current = reconcileInsert(parent, val, null, m);
|
|
196
205
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
435
|
+
reconcileKeyed(liveParent, endMarker, items, newItems, mappedNodes, disposeFns, mapFn, keyFn, keyedState);
|
|
400
436
|
} else {
|
|
401
|
-
reconcileList(
|
|
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
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
829
|
-
|
|
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
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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,
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
1200
|
+
ref = marker;
|
|
906
1201
|
}
|
|
1202
|
+
}
|
|
907
1203
|
|
|
908
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
1320
|
+
if (isSvg) {
|
|
1321
|
+
el.setAttribute('class', value || '');
|
|
1322
|
+
} else {
|
|
1323
|
+
el.className = value || '';
|
|
1324
|
+
}
|
|
979
1325
|
} else if (key === 'dangerouslySetInnerHTML') {
|
|
980
|
-
|
|
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
|
-
|
|
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
|
-
|
|
995
|
-
|
|
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
|
-
|
|
1318
|
-
|
|
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
|
}
|