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:
@@ -14,7 +14,7 @@
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
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 isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||||
@@ -1686,22 +1686,59 @@ return (isSxTruthy(testFn()) ? (function() {
|
|||||||
return marker;
|
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
|
// reactive-list
|
||||||
var reactiveList = function(mapFn, itemsSig, env, ns) { return (function() {
|
var reactiveList = function(mapFn, itemsSig, env, ns) { return (function() {
|
||||||
var container = createFragment();
|
var container = createFragment();
|
||||||
var marker = createComment("island-list");
|
var marker = createComment("island-list");
|
||||||
|
var keyMap = {};
|
||||||
|
var keyOrder = [];
|
||||||
domAppend(container, marker);
|
domAppend(container, marker);
|
||||||
effect(function() { return (function() {
|
effect(function() { return (function() {
|
||||||
var parent = domParent(marker);
|
var parent = domParent(marker);
|
||||||
return (isSxTruthy(parent) ? (domRemoveChildrenAfter(marker), (function() {
|
|
||||||
var items = deref(itemsSig);
|
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 frag = createFragment();
|
||||||
{ var _c = items; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (function() {
|
{ var _c = newKeys; for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; domAppend(frag, dictGet(newMap, k)); } }
|
||||||
var rendered = (isSxTruthy(isLambda(mapFn)) ? renderLambdaDom(mapFn, [item], env, ns) : renderToDom(apply(mapFn, [item]), env, ns));
|
|
||||||
return domAppend(frag, rendered);
|
|
||||||
})(); } }
|
|
||||||
return domInsertAfter(marker, frag);
|
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;
|
return container;
|
||||||
})(); };
|
})(); };
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
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 isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||||
@@ -1686,22 +1686,59 @@ return (isSxTruthy(testFn()) ? (function() {
|
|||||||
return marker;
|
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
|
// reactive-list
|
||||||
var reactiveList = function(mapFn, itemsSig, env, ns) { return (function() {
|
var reactiveList = function(mapFn, itemsSig, env, ns) { return (function() {
|
||||||
var container = createFragment();
|
var container = createFragment();
|
||||||
var marker = createComment("island-list");
|
var marker = createComment("island-list");
|
||||||
|
var keyMap = {};
|
||||||
|
var keyOrder = [];
|
||||||
domAppend(container, marker);
|
domAppend(container, marker);
|
||||||
effect(function() { return (function() {
|
effect(function() { return (function() {
|
||||||
var parent = domParent(marker);
|
var parent = domParent(marker);
|
||||||
return (isSxTruthy(parent) ? (domRemoveChildrenAfter(marker), (function() {
|
|
||||||
var items = deref(itemsSig);
|
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 frag = createFragment();
|
||||||
{ var _c = items; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (function() {
|
{ var _c = newKeys; for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; domAppend(frag, dictGet(newMap, k)); } }
|
||||||
var rendered = (isSxTruthy(isLambda(mapFn)) ? renderLambdaDom(mapFn, [item], env, ns) : renderToDom(apply(mapFn, [item]), env, ns));
|
|
||||||
return domAppend(frag, rendered);
|
|
||||||
})(); } }
|
|
||||||
return domInsertAfter(marker, frag);
|
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;
|
return container;
|
||||||
})(); };
|
})(); };
|
||||||
|
|||||||
@@ -580,29 +580,89 @@
|
|||||||
|
|
||||||
;; reactive-list — render a keyed list bound to a signal
|
;; reactive-list — render a keyed list bound to a signal
|
||||||
;; Used for (map fn (deref items)) inside an island.
|
;; 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
|
(define reactive-list
|
||||||
(fn (map-fn items-sig env ns)
|
(fn (map-fn items-sig env ns)
|
||||||
(let ((container (create-fragment))
|
(let ((container (create-fragment))
|
||||||
(marker (create-comment "island-list")))
|
(marker (create-comment "island-list"))
|
||||||
|
(key-map (dict))
|
||||||
|
(key-order (list)))
|
||||||
(dom-append container marker)
|
(dom-append container marker)
|
||||||
(effect (fn ()
|
(effect (fn ()
|
||||||
;; Simple strategy: clear and re-render
|
(let ((parent (dom-parent marker))
|
||||||
;; Future: keyed reconciliation
|
(items (deref items-sig)))
|
||||||
(let ((parent (dom-parent marker)))
|
|
||||||
(when parent
|
(when parent
|
||||||
;; Remove all nodes after marker until next sibling marker
|
(let ((new-map (dict))
|
||||||
(dom-remove-children-after marker)
|
(new-keys (list))
|
||||||
;; Render new items into a fragment, then insert after marker
|
(has-keys false))
|
||||||
(let ((items (deref items-sig))
|
|
||||||
(frag (create-fragment)))
|
;; Render or reuse each item
|
||||||
(for-each
|
(for-each-indexed
|
||||||
(fn (item)
|
(fn (idx item)
|
||||||
(let ((rendered (if (lambda? map-fn)
|
(let ((rendered (render-list-item map-fn item env ns))
|
||||||
(render-lambda-dom map-fn (list item) env ns)
|
(key (extract-key rendered idx)))
|
||||||
(render-to-dom (apply map-fn (list item)) env ns))))
|
(when (and (not has-keys)
|
||||||
(dom-append frag rendered)))
|
(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)
|
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)))
|
container)))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -251,7 +251,8 @@
|
|||||||
(deref (computed (fn () (len (deref items))))) " items"))
|
(deref (computed (fn () (len (deref items))))) " items"))
|
||||||
(ul :class "space-y-1"
|
(ul :class "space-y-1"
|
||||||
(map (fn (item)
|
(map (fn (item)
|
||||||
(li :class "flex items-center justify-between bg-white rounded px-3 py-2 text-sm"
|
(li :key (str (get item "id"))
|
||||||
|
:class "flex items-center justify-between bg-white rounded px-3 py-2 text-sm"
|
||||||
(span (get item "text"))
|
(span (get item "text"))
|
||||||
(button :class "text-stone-400 hover:text-red-500 text-xs"
|
(button :class "text-stone-400 hover:text-red-500 text-xs"
|
||||||
:on-click (fn (e) (remove-item (get item "id")))
|
:on-click (fn (e) (remove-item (get item "id")))
|
||||||
@@ -315,10 +316,10 @@
|
|||||||
(p "Two patterns exist: " (strong "declarative") " (" (code "(span (deref sig))") " — auto-reactive via " (code "reactive-text") ") and " (strong "imperative") " (" (code "create-text-node") " + " (code "effect") " — explicit, full control). Use declarative for simple text, imperative for dynamic classes, conditional DOM, or complex updates."))
|
(p "Two patterns exist: " (strong "declarative") " (" (code "(span (deref sig))") " — auto-reactive via " (code "reactive-text") ") and " (strong "imperative") " (" (code "create-text-node") " + " (code "effect") " — explicit, full control). Use declarative for simple text, imperative for dynamic classes, conditional DOM, or complex updates."))
|
||||||
|
|
||||||
(~doc-section :title "5. Reactive List" :id "demo-reactive-list"
|
(~doc-section :title "5. Reactive List" :id "demo-reactive-list"
|
||||||
(p "When " (code "map") " is used with " (code "(deref signal)") " inside an island, it auto-upgrades to a reactive list. Adding or removing items updates only the affected DOM — no full re-render.")
|
(p "When " (code "map") " is used with " (code "(deref signal)") " inside an island, it auto-upgrades to a reactive list. With " (code ":key") " attributes, existing DOM nodes are reused across updates — only additions, removals, and reorderings touch the DOM.")
|
||||||
(~demo-reactive-list)
|
(~demo-reactive-list)
|
||||||
(~doc-code :code (highlight "(defisland ~demo-reactive-list ()\n (let ((next-id (signal 1))\n (items (signal (list)))\n (add-item (fn (e)\n (batch (fn ()\n (swap! items (fn (old)\n (append old (dict \"id\" (deref next-id)\n \"text\" (str \"Item \" (deref next-id))))))\n (swap! next-id inc)))))\n (remove-item (fn (id)\n (swap! items (fn (old)\n (filter (fn (item) (not (= (get item \"id\") id))) old))))))\n (div\n (button :on-click add-item \"Add Item\")\n (span (deref (computed (fn () (len (deref items))))) \" items\")\n (ul\n (map (fn (item)\n (li (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items))))))" "lisp"))
|
(~doc-code :code (highlight "(defisland ~demo-reactive-list ()\n (let ((next-id (signal 1))\n (items (signal (list)))\n (add-item (fn (e)\n (batch (fn ()\n (swap! items (fn (old)\n (append old (dict \"id\" (deref next-id)\n \"text\" (str \"Item \" (deref next-id))))))\n (swap! next-id inc)))))\n (remove-item (fn (id)\n (swap! items (fn (old)\n (filter (fn (item) (not (= (get item \"id\") id))) old))))))\n (div\n (button :on-click add-item \"Add Item\")\n (span (deref (computed (fn () (len (deref items))))) \" items\")\n (ul\n (map (fn (item)\n (li :key (str (get item \"id\"))\n (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items))))))" "lisp"))
|
||||||
(p "The " (code "map") " form detects " (code "(deref items)") " is a signal and creates an effect that re-renders the list when items change. " (code "batch") " groups the two signal writes (items + next-id) into one update pass."))
|
(p (code ":key") " identifies each list item. When items change, the reconciler matches old and new keys — reusing existing DOM nodes, inserting new ones, and removing stale ones. Without keys, the list falls back to clear-and-rerender. " (code "batch") " groups the two signal writes into one update pass."))
|
||||||
|
|
||||||
(~doc-section :title "6. Input Binding" :id "demo-input-binding"
|
(~doc-section :title "6. Input Binding" :id "demo-input-binding"
|
||||||
(p "The " (code ":bind") " attribute creates a two-way link between a signal and a form element. Type in the input — the signal updates. Change the signal — the input updates. Works with text inputs, checkboxes, radios, textareas, and selects.")
|
(p "The " (code ":bind") " attribute creates a two-way link between a signal and a form element. Type in the input — the signal updates. Change the signal — the input updates. Works with text inputs, checkboxes, radios, textareas, and selects.")
|
||||||
|
|||||||
Reference in New Issue
Block a user