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 selector
package.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.