Commit9dcd0fe0Recorded16 Jul 2026Repositorysigil-web-client

web-client: keyed reconciliation in patch-children

Message

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.

Changed
 src/sigil/web/client.sgl | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
 test/test-client.sgl     | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 127 insertions(+), 6 deletions(-)
Diff
src/sigil/web/client.sglmodified
@@ -16,7 +16,10 @@
16
web-client-mount
17
web-client-update
18
web-client-clear
19
web-client-reset!)
+19
web-client-reset!
+20
;; Exported for tests: the pure child-order diff used by the keyed
+21
;; reconciler. Not part of the stable public API.
+22
web-client-child-moves)
23
24
(begin
25
(define web-client-roots '())
@@ -424,6 +427,80 @@
427
(web-client-unregister-record-tree! old-record)
428
(web-client-create-node value))))
429
+430
;; Remove the first occurrence of node from nodes (eqv? on handles).
+431
(define (web-client-remove-node node nodes)
+432
(cond
+433
((null? nodes) '())
+434
((eqv? (car nodes) node) (cdr nodes))
+435
(else (cons (car nodes) (web-client-remove-node node (cdr nodes))))))
+436
+437
;; Pure child-order diff. Given `current` (the parent's attached child
+438
;; nodes, in DOM order, after dropped nodes have been removed) and
+439
;; `target` (the desired child nodes in order), return the insert-before
+440
;; operations that turn one into the other as a list of (node . ref)
+441
;; pairs in application order; ref is the node to insert before, or #f to
+442
;; append at the end. Nodes already in relative order produce no
+443
;; operation, so the unchanged common case returns '(). Inserting an
+444
;; attached node moves it (DOM insertBefore semantics), which the
+445
;; simulation mirrors by deleting the node from the remaining current
+446
;; list. Precondition: every node in `current` also appears in `target`.
+447
(define (web-client-child-moves current target)
+448
(let loop ((current current) (target target) (ops '()))
+449
(cond
+450
((null? target) (reverse ops))
+451
((and (pair? current) (eqv? (car current) (car target)))
+452
(loop (cdr current) (cdr target) ops))
+453
(else
+454
(let ((node (car target))
+455
(ref (if (pair? current) (car current) #f)))
+456
(loop (web-client-remove-node node current)
+457
(cdr target)
+458
(cons (cons node ref) ops)))))))
+459
+460
;; Reconcile parent's real child list with the patched records WITHOUT
+461
;; detaching children that are already in place. Detaching the focused
+462
;; element blurs it (silently -- no focusout fires), and the old
+463
;; clear-everything + re-append-everything pass did exactly that to every
+464
;; child of every element on every render. Instead: explicitly remove
+465
;; dropped nodes, then insert-before only the nodes whose position
+466
;; actually changed. The unchanged common case makes zero DOM calls.
+467
(define (web-client-reconcile-children! parent old-records ordered)
+468
(let ((target (let collect ((rest ordered) (acc '()))
+469
(if (null? rest)
+470
(reverse acc)
+471
(collect (cdr rest)
+472
(cons (web-client-record-node (car rest)) acc))))))
+473
(cond
+474
;; populated -> empty: one bulk clear beats N removals.
+475
((null? target)
+476
(when (pair? old-records)
+477
(wasm-dom-replace-children parent)))
+478
;; empty -> populated (first populate of this parent): clear any
+479
;; pre-existing content (e.g. static placeholder markup in a root
+480
;; that was never rendered into) and append in order, exactly like
+481
;; the old path did on initial render.
+482
((null? old-records)
+483
(wasm-dom-replace-children parent)
+484
(for-each
+485
(lambda (node) (wasm-dom-append-child parent node))
+486
target))
+487
(else
+488
;; Drop old top-level nodes not carried into the new set, keeping
+489
;; the still-attached nodes in their current DOM order.
+490
(let ((current (let prune ((rest old-records) (acc '()))
+491
(if (null? rest)
+492
(reverse acc)
+493
(let ((node (web-client-record-node (car rest))))
+494
(if (memv node target)
+495
(prune (cdr rest) (cons node acc))
+496
(begin
+497
(wasm-dom-remove-child parent node)
+498
(prune (cdr rest) acc))))))))
+499
(for-each
+500
(lambda (op)
+501
(wasm-dom-insert-before parent (car op) (cdr op)))
+502
(web-client-child-moves current target)))))))
+503
504
(define (web-client-patch-children parent old-records new-values)
505
(let loop ((rest (web-client-normalize-children new-values))
506
(index 0)
@@ -431,11 +508,7 @@
508
(if (null? rest)
509
(let ((ordered (reverse records)))
510
(web-client-unregister-dropped! old-records ordered)
434
(wasm-dom-replace-children parent)
435
(for-each
436
(lambda (record)
437
(wasm-dom-append-child parent (web-client-record-node record)))
438
ordered)
+511
(web-client-reconcile-children! parent old-records ordered)
512
ordered)
513
(let* ((value (car rest))
514
(key (web-client-key value))
test/test-client.sglmodified
@@ -22,3 +22,51 @@
22
(test-group "web-client-reset!"
23
(test "clears retained root state"
24
(assert-true (web-client-reset!))))
+25
+26
;; The pure child-order diff behind the keyed reconciler. Node handles are
+27
;; plain integers here, exactly as the DOM bridge represents them. Each op is
+28
;; (node . ref): insert node before ref, ref #f = append at the end.
+29
(test-group "web-client-child-moves"
+30
(test "unchanged order produces zero operations"
+31
(assert-equal '() (web-client-child-moves '(1 2 3) '(1 2 3)))
+32
(assert-equal '() (web-client-child-moves '(7) '(7)))
+33
(assert-equal '() (web-client-child-moves '() '())))
+34
+35
(test "initial population appends every node in order"
+36
(assert-equal '((1 . #f) (2 . #f) (3 . #f))
+37
(web-client-child-moves '() '(1 2 3))))
+38
+39
(test "appending at the end appends only the new nodes"
+40
(assert-equal '((3 . #f) (4 . #f))
+41
(web-client-child-moves '(1 2) '(1 2 3 4))))
+42
+43
(test "inserting in the middle inserts before the displaced node"
+44
(assert-equal '((2 . 3))
+45
(web-client-child-moves '(1 3) '(1 2 3))))
+46
+47
(test "inserting at the front inserts before the old head"
+48
(assert-equal '((9 . 1))
+49
(web-client-child-moves '(1 2) '(9 1 2))))
+50
+51
(test "moving the last node to the front is a single move"
+52
(assert-equal '((3 . 1))
+53
(web-client-child-moves '(1 2 3) '(3 1 2))))
+54
+55
(test "swapping adjacent nodes is a single move"
+56
(assert-equal '((3 . 2))
+57
(web-client-child-moves '(1 2 3) '(1 3 2))))
+58
+59
(test "reversal moves all but the anchor node"
+60
(assert-equal '((3 . 1) (2 . 1))
+61
(web-client-child-moves '(1 2 3) '(3 2 1))))
+62
+63
(test "removal alone needs no moves (dropped nodes are pruned first)"
+64
;; patch-children removes dropped nodes before diffing, so current
+65
;; arrives already pruned: '(1 3) against target '(1 3).
+66
(assert-equal '() (web-client-child-moves '(1 3) '(1 3))))
+67
+68
(test "mixed insert + reorder + append"
+69
;; current [1 2 3] -> target [2 9 1 3 4]
+70
;; 2 moves to front; 9 is new before 1; 3 already after 1; 4 appended.
+71
(assert-equal '((2 . 1) (9 . 1) (4 . #f))
+72
(web-client-child-moves '(1 2 3) '(2 9 1 3 4)))))