Add keyed list reconciliation to reactive-list

Items with :key attributes are matched by key across renders — existing
DOM nodes are reused, stale nodes removed, new nodes inserted in order.
Falls back to clear-and-rerender without keys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 16:15:15 +00:00
parent 8683cf24c3
commit 3103d7ff9d
4 changed files with 169 additions and 34 deletions

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-08T16:03:46Z";
var SX_VERSION = "2026-03-08T16:12:12Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -1686,22 +1686,59 @@ return (isSxTruthy(testFn()) ? (function() {
return marker;
})(); };
// render-list-item
var renderListItem = function(mapFn, item, env, ns) { return (isSxTruthy(isLambda(mapFn)) ? renderLambdaDom(mapFn, [item], env, ns) : renderToDom(apply(mapFn, [item]), env, ns)); };
// extract-key
var extractKey = function(node, index) { return (function() {
var k = domGetAttr(node, "key");
return (isSxTruthy(k) ? (domRemoveAttr(node, "key"), k) : (function() {
var dk = domGetData(node, "key");
return (isSxTruthy(dk) ? (String(dk)) : (String("__idx_") + String(index)));
})());
})(); };
// reactive-list
var reactiveList = function(mapFn, itemsSig, env, ns) { return (function() {
var container = createFragment();
var marker = createComment("island-list");
var keyMap = {};
var keyOrder = [];
domAppend(container, marker);
effect(function() { return (function() {
var parent = domParent(marker);
return (isSxTruthy(parent) ? (domRemoveChildrenAfter(marker), (function() {
var items = deref(itemsSig);
return (isSxTruthy(parent) ? (function() {
var newMap = {};
var newKeys = [];
var hasKeys = false;
forEachIndexed(function(idx, item) { return (function() {
var rendered = renderListItem(mapFn, item, env, ns);
var key = extractKey(rendered, idx);
if (isSxTruthy((isSxTruthy(!isSxTruthy(hasKeys)) && !isSxTruthy(startsWith(key, "__idx_"))))) {
hasKeys = true;
}
(isSxTruthy(dictHas(keyMap, key)) ? dictSet(newMap, key, dictGet(keyMap, key)) : dictSet(newMap, key, rendered));
return append_b(newKeys, key);
})(); }, items);
(isSxTruthy(!isSxTruthy(hasKeys)) ? (domRemoveChildrenAfter(marker), (function() {
var frag = createFragment();
{ var _c = items; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (function() {
var rendered = (isSxTruthy(isLambda(mapFn)) ? renderLambdaDom(mapFn, [item], env, ns) : renderToDom(apply(mapFn, [item]), env, ns));
return domAppend(frag, rendered);
})(); } }
{ var _c = newKeys; for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; domAppend(frag, dictGet(newMap, k)); } }
return domInsertAfter(marker, frag);
})()) : NIL);
})()) : (forEach(function(oldKey) { return (isSxTruthy(!isSxTruthy(dictHas(newMap, oldKey))) ? domRemove(dictGet(keyMap, oldKey)) : NIL); }, keyOrder), (function() {
var cursor = marker;
return forEach(function(k) { return (function() {
var node = dictGet(newMap, k);
var next = domNextSibling(cursor);
if (isSxTruthy(!isSxTruthy(isIdentical(node, next)))) {
domInsertAfter(cursor, node);
}
return (cursor = node);
})(); }, newKeys);
})()));
keyMap = newMap;
return (keyOrder = newKeys);
})() : NIL);
})(); });
return container;
})(); };

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-08T16:03:46Z";
var SX_VERSION = "2026-03-08T16:12:12Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -1686,22 +1686,59 @@ return (isSxTruthy(testFn()) ? (function() {
return marker;
})(); };
// render-list-item
var renderListItem = function(mapFn, item, env, ns) { return (isSxTruthy(isLambda(mapFn)) ? renderLambdaDom(mapFn, [item], env, ns) : renderToDom(apply(mapFn, [item]), env, ns)); };
// extract-key
var extractKey = function(node, index) { return (function() {
var k = domGetAttr(node, "key");
return (isSxTruthy(k) ? (domRemoveAttr(node, "key"), k) : (function() {
var dk = domGetData(node, "key");
return (isSxTruthy(dk) ? (String(dk)) : (String("__idx_") + String(index)));
})());
})(); };
// reactive-list
var reactiveList = function(mapFn, itemsSig, env, ns) { return (function() {
var container = createFragment();
var marker = createComment("island-list");
var keyMap = {};
var keyOrder = [];
domAppend(container, marker);
effect(function() { return (function() {
var parent = domParent(marker);
return (isSxTruthy(parent) ? (domRemoveChildrenAfter(marker), (function() {
var items = deref(itemsSig);
return (isSxTruthy(parent) ? (function() {
var newMap = {};
var newKeys = [];
var hasKeys = false;
forEachIndexed(function(idx, item) { return (function() {
var rendered = renderListItem(mapFn, item, env, ns);
var key = extractKey(rendered, idx);
if (isSxTruthy((isSxTruthy(!isSxTruthy(hasKeys)) && !isSxTruthy(startsWith(key, "__idx_"))))) {
hasKeys = true;
}
(isSxTruthy(dictHas(keyMap, key)) ? dictSet(newMap, key, dictGet(keyMap, key)) : dictSet(newMap, key, rendered));
return append_b(newKeys, key);
})(); }, items);
(isSxTruthy(!isSxTruthy(hasKeys)) ? (domRemoveChildrenAfter(marker), (function() {
var frag = createFragment();
{ var _c = items; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (function() {
var rendered = (isSxTruthy(isLambda(mapFn)) ? renderLambdaDom(mapFn, [item], env, ns) : renderToDom(apply(mapFn, [item]), env, ns));
return domAppend(frag, rendered);
})(); } }
{ var _c = newKeys; for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; domAppend(frag, dictGet(newMap, k)); } }
return domInsertAfter(marker, frag);
})()) : NIL);
})()) : (forEach(function(oldKey) { return (isSxTruthy(!isSxTruthy(dictHas(newMap, oldKey))) ? domRemove(dictGet(keyMap, oldKey)) : NIL); }, keyOrder), (function() {
var cursor = marker;
return forEach(function(k) { return (function() {
var node = dictGet(newMap, k);
var next = domNextSibling(cursor);
if (isSxTruthy(!isSxTruthy(isIdentical(node, next)))) {
domInsertAfter(cursor, node);
}
return (cursor = node);
})(); }, newKeys);
})()));
keyMap = newMap;
return (keyOrder = newKeys);
})() : NIL);
})(); });
return container;
})(); };

View File

@@ -580,29 +580,89 @@
;; reactive-list — render a keyed list bound to a signal
;; Used for (map fn (deref items)) inside an island.
;;
;; Keyed reconciliation: if rendered elements have a "key" attribute,
;; existing DOM nodes are reused across updates. Only additions, removals,
;; and reorderings touch the DOM. Without keys, falls back to clear+rerender.
(define render-list-item
(fn (map-fn item env ns)
(if (lambda? map-fn)
(render-lambda-dom map-fn (list item) env ns)
(render-to-dom (apply map-fn (list item)) env ns))))
(define extract-key
(fn (node index)
;; Extract key from rendered node: :key attr, data-key, or index fallback
(let ((k (dom-get-attr node "key")))
(if k
(do (dom-remove-attr node "key") k)
(let ((dk (dom-get-data node "key")))
(if dk (str dk) (str "__idx_" index)))))))
(define reactive-list
(fn (map-fn items-sig env ns)
(let ((container (create-fragment))
(marker (create-comment "island-list")))
(marker (create-comment "island-list"))
(key-map (dict))
(key-order (list)))
(dom-append container marker)
(effect (fn ()
;; Simple strategy: clear and re-render
;; Future: keyed reconciliation
(let ((parent (dom-parent marker)))
(let ((parent (dom-parent marker))
(items (deref items-sig)))
(when parent
;; Remove all nodes after marker until next sibling marker
(dom-remove-children-after marker)
;; Render new items into a fragment, then insert after marker
(let ((items (deref items-sig))
(frag (create-fragment)))
(for-each
(fn (item)
(let ((rendered (if (lambda? map-fn)
(render-lambda-dom map-fn (list item) env ns)
(render-to-dom (apply map-fn (list item)) env ns))))
(dom-append frag rendered)))
(let ((new-map (dict))
(new-keys (list))
(has-keys false))
;; Render or reuse each item
(for-each-indexed
(fn (idx item)
(let ((rendered (render-list-item map-fn item env ns))
(key (extract-key rendered idx)))
(when (and (not has-keys)
(not (starts-with? key "__idx_")))
(set! has-keys true))
;; Reuse existing node if key matches, else use new
(if (dict-has? key-map key)
(dict-set! new-map key (dict-get key-map key))
(dict-set! new-map key rendered))
(append! new-keys key)))
items)
(dom-insert-after marker frag))))))
(if (not has-keys)
;; No keys: simple clear and re-render (original strategy)
(do
(dom-remove-children-after marker)
(let ((frag (create-fragment)))
(for-each
(fn (k) (dom-append frag (dict-get new-map k)))
new-keys)
(dom-insert-after marker frag)))
;; Keyed reconciliation
(do
;; Remove stale nodes
(for-each
(fn (old-key)
(when (not (dict-has? new-map old-key))
(dom-remove (dict-get key-map old-key))))
key-order)
;; Reorder/insert to match new key order
(let ((cursor marker))
(for-each
(fn (k)
(let ((node (dict-get new-map k))
(next (dom-next-sibling cursor)))
(when (not (identical? node next))
(dom-insert-after cursor node))
(set! cursor node)))
new-keys))))
;; Update state for next render
(set! key-map new-map)
(set! key-order new-keys))))))
container)))