Commit0d608effRecorded31 Aug 2026Repositorysigil-web-client
sigil-web-client: v0.15.4 memoized subtrees
Changed
README.md | 22 ++++++++++++++++++++++
package.sgl | 2 +-
src/sigil/web/client.sgl | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
test/test-client.sgl | 29 +++++++++++++++++++++++++++++
4 files changed, 138 insertions(+), 14 deletions(-)Diff
README.mdmodified
@@ -22,6 +22,28 @@ Client-side WASM UI framework for [Sigil](https://codeberg.org/sigil/sigil). A r
22
(web-client-clear root) 23
``` 24
+25
### Memoized subtrees+26
+27
Use `web-client-memo` when a rendered subtree is immutable for a known+28
revision:+29
+30
```scheme+31
(web-client-memo+32
turn-id turn-revision+33
(lambda ()+34
`(section ,@(render-completed-turn turn))))+35
```+36
+37
When a later `web-client-update` produces the same key and revision,+38
reconciliation retains that element's complete DOM subtree without evaluating+39
the producer or walking its attributes and descendants. A changed revision+40
evaluates the producer and resumes normal reconciliation. The key and revision+41
are renderer metadata and are not written to the DOM.+42
+43
Memoized content must be immutable for the lifetime of its revision, including+44
event handlers and attributes on the boundary element. Use this for stable,+45
expensive regions while leaving actively changing siblings unmemoized.+46
47
### Exports 48
49
- `web-client-root` — look up a DOM root node by selectorpackage.sglmodified
@@ -14,7 +14,7 @@
14
15
(package 16
name: "sigil-web-client"−17
version: "0.15.3"+17
version: "0.15.4" 18
sigil: "^0.17" 19
description: "Client-side WASM UI framework for Sigil: react-like browser app rendering" 20
url: "https://codeberg.org/sigil/sigil-web-client"src/sigil/web/client.sglmodified
@@ -17,6 +17,7 @@
17
web-client-update 18
web-client-clear 19
web-client-reset!+20
web-client-memo 21
;; Exported for tests: the pure child-order diff used by the keyed 22
;; reconciler. Not part of the stable public API. 23
web-client-child-moves@@ -24,7 +25,10 @@
25
web-client-children-intact? 26
;; Exported for tests: the SXML child-content normalizer. Not part of 27
;; the stable public API.−27
web-client-normalize-children)+28
web-client-normalize-children+29
;; Exported for tests: the pure memo-boundary predicate. Not part of the+30
;; stable public API.+31
web-client-memo-unchanged?) 32
33
(begin 34
(define web-client-roots '())@@ -75,10 +79,48 @@
79
((boolean? value) (if value "true" "false")) 80
(else ""))) 81
+82
;; A lazy immutable subtree. Its producer is evaluated only when the keyed+83
;; revision is new or changes; an unchanged retained record returns before+84
;; the body is constructed. The producer must return one SXML element.+85
(define (web-client-memo key revision producer)+86
(vector 'web-client-memo+87
(web-client-value->string key)+88
(web-client-value->string revision)+89
producer))+90
+91
(define (web-client-memo? value)+92
(and (vector? value) (= (vector-length value) 4)+93
(eq? (vector-ref value 0) 'web-client-memo)))+94
+95
(define (web-client-memo-key value) (vector-ref value 1))+96
(define (web-client-memo-revision value) (vector-ref value 2))+97
+98
(define (web-client-with-memo-attrs value key revision)+99
(if (not (web-client-sxml-element? value))+100
(error "web-client-memo producer must return one SXML element")+101
(let* ((attrs (web-client-sxml-attributes value))+102
(ordinary (filter (lambda (attr)+103
(not (and (pair? attr)+104
(or (eq? (car attr) 'key)+105
(eq? (car attr) 'memo)))))+106
attrs)))+107
(cons (web-client-sxml-tag value)+108
(cons (cons '@ (append `((key ,key) (memo ,revision)) ordinary))+109
(web-client-sxml-content value))))))+110
+111
(define (web-client-resolve-memo value)+112
(let ((producer (vector-ref value 3)))+113
(web-client-with-memo-attrs+114
(if (procedure? producer) (producer) producer)+115
(web-client-memo-key value)+116
(web-client-memo-revision value))))+117
118
(define (web-client-dom-attribute? attr) 119
(and (pair? attr) 120
(symbol? (car attr))−81
(not (eq? (car attr) 'key))))+121
;; key and memo are reconciler metadata, never DOM attributes.+122
(not (eq? (car attr) 'key))+123
(not (eq? (car attr) 'memo)))) 124
125
(define (web-client-find-attr attrs name) 126
(let loop ((rest attrs))@@ -103,9 +145,26 @@
145
#f))) 146
147
(define (web-client-key value)−106
(if (web-client-sxml-element? value)−107
(web-client-key-from-attrs (web-client-sxml-attributes value))−108
#f))+148
(cond ((web-client-memo? value) (web-client-memo-key value))+149
((web-client-sxml-element? value)+150
(web-client-key-from-attrs (web-client-sxml-attributes value)))+151
(else #f)))+152
+153
;; A keyed element with an unchanged memo revision is an immutable subtree+154
;; boundary. The caller still constructs fresh SXML, but reconciliation can+155
;; retain the complete old record (DOM nodes, attributes, listeners, and+156
;; descendants) without walking it. Requiring a stable key prevents an+157
;; index-matched sibling from accidentally inheriting another subtree's+158
;; memo revision after insertion or reordering.+159
(define (web-client-memo-unchanged? old-attrs new-attrs)+160
(let ((old-key (web-client-key-from-attrs old-attrs))+161
(new-key (web-client-key-from-attrs new-attrs))+162
(old-memo (web-client-find-attr old-attrs 'memo))+163
(new-memo (web-client-find-attr new-attrs 'memo)))+164
(and old-key new-key+165
(string=? old-key new-key)+166
old-memo new-memo+167
(web-client-attr-equal? old-memo new-memo)))) 168
169
(define (web-client-record-kind record) 170
(if (pair? record) (car record) #f))@@ -357,6 +416,8 @@
416
417
(define (web-client-create-node value) 418
(cond+419
((web-client-memo? value)+420
(web-client-create-node (web-client-resolve-memo value))) 421
((web-client-text-value? value) 422
(let* ((text (web-client-text-value value)) 423
(node (wasm-dom-create-text-node text)))@@ -410,6 +471,16 @@
471
(else #f))) 472
473
(define (web-client-patch-node parent old-record value)+474
(if (web-client-memo? value)+475
(let ((new-attrs `((key ,(web-client-memo-key value))+476
(memo ,(web-client-memo-revision value)))))+477
(if (and old-record+478
(eq? (web-client-record-kind old-record) 'element)+479
(web-client-memo-unchanged?+480
(web-client-record-attrs old-record) new-attrs))+481
old-record+482
(web-client-patch-node parent old-record+483
(web-client-resolve-memo value)))) 484
(if (web-client-compatible? old-record value) 485
(cond 486
((web-client-text-value? value)@@ -422,19 +493,21 @@
493
(let* ((node (web-client-record-node old-record)) 494
(tag (web-client-sxml-tag value)) 495
(old-attrs (web-client-record-attrs old-record))−425
(new-attrs (web-client-sxml-attributes value))−426
(children (web-client-patch-children−427
node−428
(web-client-record-children old-record)−429
(web-client-sxml-content value))))−430
(web-client-apply-attrs node old-attrs new-attrs)−431
(list 'element node tag new-attrs children)))+496
(new-attrs (web-client-sxml-attributes value)))+497
(if (web-client-memo-unchanged? old-attrs new-attrs)+498
old-record+499
(let ((children (web-client-patch-children+500
node+501
(web-client-record-children old-record)+502
(web-client-sxml-content value))))+503
(web-client-apply-attrs node old-attrs new-attrs)+504
(list 'element node tag new-attrs children))))) 505
(else (web-client-create-node value))) 506
(begin 507
;; Incompatible: the old node (and its subtree) is being replaced -- 508
;; drop its listeners before the new node supplants it. 509
(web-client-unregister-record-tree! old-record)−437
(web-client-create-node value))))+510
(web-client-create-node value))))) 511
512
;; Remove the first occurrence of node from nodes (eqv? on handles). 513
(define (web-client-remove-node node nodes)test/test-client.sglmodified
@@ -19,6 +19,12 @@
19
(assert-equal '(span "Hi") 20
(web-client-render (lambda () '(span "Hi")))))) 21
+22
(test "web-client-memo construction is lazy"+23
(let ((called? #f))+24
(web-client-memo "turn-1" "complete"+25
(lambda () (set! called? #t) '(div "body")))+26
(assert-false called?)))+27
28
(test-group "web-client-reset!" 29
(test "clears retained root state" 30
(assert-true (web-client-reset!))))@@ -42,6 +48,29 @@
48
(test "a single atom is wrapped as one child" 49
(assert-equal '("hello") (web-client-normalize-children "hello")))) 50
+51
(test-group "web-client-memo-unchanged?"+52
(test "same key and revision retain a memoized subtree"+53
(assert-true+54
(web-client-memo-unchanged?+55
'((key "turn-1") (memo 4) (class "old"))+56
'((key "turn-1") (memo 4) (class "new")))))+57
+58
(test "a changed revision invalidates the boundary"+59
(assert-false+60
(web-client-memo-unchanged?+61
'((key "turn-1") (memo 4))+62
'((key "turn-1") (memo 5)))))+63
+64
(test "a changed key cannot inherit a memoized subtree"+65
(assert-false+66
(web-client-memo-unchanged?+67
'((key "turn-1") (memo 4))+68
'((key "turn-2") (memo 4)))))+69
+70
(test "memo metadata without a key is never a boundary"+71
(assert-false+72
(web-client-memo-unchanged? '((memo 4)) '((memo 4))))))+73
74
;; The pure child-order diff behind the keyed reconciler. Node handles are 75
;; plain integers here, exactly as the DOM bridge represents them. Each op is 76
;; (node . ref): insert node before ref, ref #f = append at the end.