diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 6ca3b4d..4dc5628 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -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; })(); }; diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index 6ca3b4d..4dc5628 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -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; })(); }; diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index 84156c8..6fb66b5 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -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))) diff --git a/sx/sx/reactive-islands.sx b/sx/sx/reactive-islands.sx index 38f3547..6037331 100644 --- a/sx/sx/reactive-islands.sx +++ b/sx/sx/reactive-islands.sx @@ -251,7 +251,8 @@ (deref (computed (fn () (len (deref items))))) " items")) (ul :class "space-y-1" (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")) (button :class "text-stone-400 hover:text-red-500 text-xs" :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.")) (~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) - (~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")) - (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.")) + (~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 (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" (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.")