web-client: keyed reconciliation in patch-children
patch-children used to patch each child in place and then, at every element at every level on every render, clear the parent's child list (wasm-dom-replace-children) and re-append every node. Detaching the focused element blurs it silently (no focusout fires), which is how every render dropped focus to <body>, and the clear+re-append recursion cost ~100 DOM mutations on a single interaction.
Replace that with real keyed reconciliation:
- web-client-child-moves: pure child-order diff. Walks the target order against the still-attached child list and emits (node . ref) insert-before ops only for nodes whose position actually changed; the unchanged common case emits nothing. - web-client-reconcile-children!: explicitly removes dropped nodes (wasm-dom-remove-child), then applies the move ops (wasm-dom-insert-before). In-place children are never detached, so focus survives renders. populated->empty keeps the single bulk replace-children call; empty->populated keeps the old clear+append behavior so a never-rendered root still drops pre-existing static placeholder markup.
Requires sigil-wasm-dom with the insert-before/remove-child primitives (monorepo branch fix/web-client-keyed-reconciliation, ships in the next monorepo release; bump the dependency pin at release).
web-client-child-moves is exported for tests (documented as internal); test-client.sgl covers unchanged order, initial population, append, front/middle insert, single-move reorders, reversal, and a mixed insert+reorder+append case.
src/sigil/web/client.sgl | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
test/test-client.sgl | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 127 insertions(+), 6 deletions(-)src/sigil/web/client.sglmodified
web-client-mount web-client-update web-client-clear web-client-reset!) web-client-reset! ;; Exported for tests: the pure child-order diff used by the keyed ;; reconciler. Not part of the stable public API. web-client-child-moves) (begin (define web-client-roots '()) (web-client-unregister-record-tree! old-record) (web-client-create-node value)))) ;; Remove the first occurrence of node from nodes (eqv? on handles). (define (web-client-remove-node node nodes) (cond ((null? nodes) '()) ((eqv? (car nodes) node) (cdr nodes)) (else (cons (car nodes) (web-client-remove-node node (cdr nodes)))))) ;; Pure child-order diff. Given `current` (the parent's attached child ;; nodes, in DOM order, after dropped nodes have been removed) and ;; `target` (the desired child nodes in order), return the insert-before ;; operations that turn one into the other as a list of (node . ref) ;; pairs in application order; ref is the node to insert before, or #f to ;; append at the end. Nodes already in relative order produce no ;; operation, so the unchanged common case returns '(). Inserting an ;; attached node moves it (DOM insertBefore semantics), which the ;; simulation mirrors by deleting the node from the remaining current ;; list. Precondition: every node in `current` also appears in `target`. (define (web-client-child-moves current target) (let loop ((current current) (target target) (ops '())) (cond ((null? target) (reverse ops)) ((and (pair? current) (eqv? (car current) (car target))) (loop (cdr current) (cdr target) ops)) (else (let ((node (car target)) (ref (if (pair? current) (car current) #f))) (loop (web-client-remove-node node current) (cdr target) (cons (cons node ref) ops))))))) ;; Reconcile parent's real child list with the patched records WITHOUT ;; detaching children that are already in place. Detaching the focused ;; element blurs it (silently -- no focusout fires), and the old ;; clear-everything + re-append-everything pass did exactly that to every ;; child of every element on every render. Instead: explicitly remove ;; dropped nodes, then insert-before only the nodes whose position ;; actually changed. The unchanged common case makes zero DOM calls. (define (web-client-reconcile-children! parent old-records ordered) (let ((target (let collect ((rest ordered) (acc '())) (if (null? rest) (reverse acc) (collect (cdr rest) (cons (web-client-record-node (car rest)) acc)))))) (cond ;; populated -> empty: one bulk clear beats N removals. ((null? target) (when (pair? old-records) (wasm-dom-replace-children parent))) ;; empty -> populated (first populate of this parent): clear any ;; pre-existing content (e.g. static placeholder markup in a root ;; that was never rendered into) and append in order, exactly like ;; the old path did on initial render. ((null? old-records) (wasm-dom-replace-children parent) (for-each (lambda (node) (wasm-dom-append-child parent node)) target)) (else ;; Drop old top-level nodes not carried into the new set, keeping ;; the still-attached nodes in their current DOM order. (let ((current (let prune ((rest old-records) (acc '())) (if (null? rest) (reverse acc) (let ((node (web-client-record-node (car rest)))) (if (memv node target) (prune (cdr rest) (cons node acc)) (begin (wasm-dom-remove-child parent node) (prune (cdr rest) acc)))))))) (for-each (lambda (op) (wasm-dom-insert-before parent (car op) (cdr op))) (web-client-child-moves current target))))))) (define (web-client-patch-children parent old-records new-values) (let loop ((rest (web-client-normalize-children new-values)) (index 0) (if (null? rest) (let ((ordered (reverse records))) (web-client-unregister-dropped! old-records ordered) (wasm-dom-replace-children parent) (for-each (lambda (record) (wasm-dom-append-child parent (web-client-record-node record))) ordered) (web-client-reconcile-children! parent old-records ordered) ordered) (let* ((value (car rest)) (key (web-client-key value))test/test-client.sglmodified
(test-group "web-client-reset!" (test "clears retained root state" (assert-true (web-client-reset!))));; The pure child-order diff behind the keyed reconciler. Node handles are;; plain integers here, exactly as the DOM bridge represents them. Each op is;; (node . ref): insert node before ref, ref #f = append at the end.(test-group "web-client-child-moves" (test "unchanged order produces zero operations" (assert-equal '() (web-client-child-moves '(1 2 3) '(1 2 3))) (assert-equal '() (web-client-child-moves '(7) '(7))) (assert-equal '() (web-client-child-moves '() '()))) (test "initial population appends every node in order" (assert-equal '((1 . #f) (2 . #f) (3 . #f)) (web-client-child-moves '() '(1 2 3)))) (test "appending at the end appends only the new nodes" (assert-equal '((3 . #f) (4 . #f)) (web-client-child-moves '(1 2) '(1 2 3 4)))) (test "inserting in the middle inserts before the displaced node" (assert-equal '((2 . 3)) (web-client-child-moves '(1 3) '(1 2 3)))) (test "inserting at the front inserts before the old head" (assert-equal '((9 . 1)) (web-client-child-moves '(1 2) '(9 1 2)))) (test "moving the last node to the front is a single move" (assert-equal '((3 . 1)) (web-client-child-moves '(1 2 3) '(3 1 2)))) (test "swapping adjacent nodes is a single move" (assert-equal '((3 . 2)) (web-client-child-moves '(1 2 3) '(1 3 2)))) (test "reversal moves all but the anchor node" (assert-equal '((3 . 1) (2 . 1)) (web-client-child-moves '(1 2 3) '(3 2 1)))) (test "removal alone needs no moves (dropped nodes are pruned first)" ;; patch-children removes dropped nodes before diffing, so current ;; arrives already pruned: '(1 3) against target '(1 3). (assert-equal '() (web-client-child-moves '(1 3) '(1 3)))) (test "mixed insert + reorder + append" ;; current [1 2 3] -> target [2 9 1 3 4] ;; 2 moves to front; 9 is new before 1; 3 already after 1; 4 appended. (assert-equal '((2 . 1) (9 . 1) (4 . #f)) (web-client-child-moves '(1 2 3) '(2 9 1 3 4)))))