Commitaec12ae3Recorded13 Jul 2026Repositorysigil-web-client
web-client: recognize procedure-valued on-*: event attributes
Message
Wire the on-*: DOM-event convention into the renderer (slate-core.md §9 gap 1). An sxml attribute whose name is a keyword of the form on-<event>: and whose value is a procedure is registered as a DOM event listener via the bridge instead of being ignored:
- A node-keyed closure registry (keyed by the integer DOM handle, stable across patches since the diff reuses nodes) plus a lazily-installed dispatcher that the bridge routes every fired event to.
- web-client-apply-attrs gains two additive passes: detach listeners gone from the new attrs, then (re)register the ones present. Plain string/symbol attributes take the exact same path as before (keyword attrs were already skipped by web-client-dom-attribute?).
- Listeners are unregistered on node removal/replace (patch-node clears an incompatible old subtree; patch-children clears dropped top-level records). This is cleanup only; the reconciliation strategy is unchanged.
on-key: maps to "keydown"; every other on-<x>: maps to "<x>". The closure receives a small general props dict (type, key, is-composing, value, caret); returning #t requests preventDefault.
Changed
src/sigil/web/client.sgl | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 163 insertions(+), 1 deletion(-)Diff
src/sigil/web/client.sglmodified
@@ -154,6 +154,145 @@
154
value) 155
(else (list value)))) 156
+157
;; ---- on-*: DOM event listeners -----------------------------------------+158
;; An attribute whose name is a keyword of the form on-<event>: and whose+159
;; value is a procedure is wired as a DOM event listener through the bridge,+160
;; keyed by node identity, instead of being treated as a string attribute.+161
;; When the event fires the bridge calls web-client-dispatch-dom-event, which+162
;; finds the closure registered for that node + event type and applies it+163
;; with a small event props dict. Plain string/symbol attributes are+164
;; untouched by this path.+165
+166
;; Registry: an alist of (node-id . type-alist), where type-alist maps an+167
;; event-type string to the closure currently bound for it. node-id is the+168
;; integer DOM handle the bridge assigns; it is stable across patches because+169
;; the diff reuses nodes, so it is a durable identity key.+170
(define web-client-listeners '())+171
(define web-client-dispatcher-installed? #f)+172
+173
;; Install the single dispatch handler the bridge routes every fired event+174
;; to, once, lazily on the first listener registration.+175
(define (web-client-ensure-dispatcher!)+176
(when (not web-client-dispatcher-installed?)+177
(wasm-dom-set-event-handler web-client-dispatch-dom-event)+178
(set! web-client-dispatcher-installed? #t)))+179
+180
;; Map an on-<event>: attribute keyword to a DOM event type string. on-key:+181
;; is a convenience alias for "keydown"; every other on-<x>: maps to "<x>",+182
;; so the convention stays general (on-change:, on-blur:, ... all work).+183
(define (web-client-event-type-name attr-key)+184
(let ((name (keyword->string attr-key)))+185
(if (and (>= (string-length name) 3)+186
(string=? (substring name 0 3) "on-"))+187
(let ((evt (substring name 3 (string-length name))))+188
(if (string=? evt "key") "keydown" evt))+189
#f)))+190
+191
;; An on-*: listener attribute: a keyword of the on-<event>: form whose value+192
;; is a procedure. Returns the event-type string, or #f if not a listener.+193
(define (web-client-event-attr-type attr)+194
(and (pair? attr)+195
(keyword? (car attr))+196
(procedure? (web-client-attribute-value attr))+197
(web-client-event-type-name (car attr))))+198
+199
(define (web-client-node-listeners node)+200
(let loop ((rest web-client-listeners))+201
(cond+202
((null? rest) #f)+203
((eqv? (car (car rest)) node) (car rest))+204
(else (loop (cdr rest))))))+205
+206
(define (web-client-alist-delete key alist)+207
(cond+208
((null? alist) '())+209
((equal? (car (car alist)) key) (cdr alist))+210
(else (cons (car alist) (web-client-alist-delete key (cdr alist))))))+211
+212
;; Register (or refresh) the closure for node + event type. The bridge-level+213
;; addEventListener happens only the first time a given node + type is seen;+214
;; later renders just swap the stored closure (the JS listener is stable and+215
;; always looks up the current closure), so a re-created lambda each render+216
;; costs nothing at the DOM layer.+217
(define (web-client-listener-set! node type closure)+218
(web-client-ensure-dispatcher!)+219
(let ((entry (web-client-node-listeners node)))+220
(if entry+221
(let ((existing (assoc type (cdr entry))))+222
(if existing+223
(set-cdr! existing closure)+224
(begin+225
(wasm-dom-add-event-listener node type)+226
(set-cdr! entry (cons (cons type closure) (cdr entry))))))+227
(begin+228
(wasm-dom-add-event-listener node type)+229
(set! web-client-listeners+230
(cons (cons node (list (cons type closure)))+231
web-client-listeners))))))+232
+233
(define (web-client-listener-remove! node type)+234
(let ((entry (web-client-node-listeners node)))+235
(when entry+236
(when (assoc type (cdr entry))+237
(wasm-dom-remove-event-listener node type)+238
(let ((rest (web-client-alist-delete type (cdr entry))))+239
;; Prune the node's entry entirely once its last listener is gone,+240
;; so the registry never accumulates empty (node . ()) entries.+241
(if (null? rest)+242
(set! web-client-listeners+243
(web-client-alist-delete node web-client-listeners))+244
(set-cdr! entry rest)))))))+245
+246
;; Detach every listener on a node and drop its registry entry. Used when a+247
;; node is removed or replaced so listeners never outlive their node.+248
(define (web-client-listeners-clear-node! node)+249
(let ((entry (web-client-node-listeners node)))+250
(when entry+251
(for-each (lambda (pair) (wasm-dom-remove-event-listener node (car pair)))+252
(cdr entry))+253
(set! web-client-listeners+254
(web-client-alist-delete node web-client-listeners)))))+255
+256
;; The dispatch handler the bridge invokes for every fired event: route to+257
;; the closure registered for this node + event type, applied with the props+258
;; dict. A #t result propagates back so the bridge can preventDefault.+259
(define (web-client-dispatch-dom-event node props)+260
(let ((entry (web-client-node-listeners node)))+261
(if entry+262
(let ((existing (assoc (dict-ref props type: "") (cdr entry))))+263
(if existing ((cdr existing) props) #f))+264
#f)))+265
+266
;; Recursively drop listeners for an element record and its subtree.+267
(define (web-client-unregister-record-tree! record)+268
(when (and record (pair? record)+269
(eq? (web-client-record-kind record) 'element))+270
(web-client-listeners-clear-node! (web-client-record-node record))+271
(for-each web-client-unregister-record-tree!+272
(web-client-record-children record))))+273
+274
(define (web-client-element-nodes records)+275
(let loop ((rest records) (acc '()))+276
(cond+277
((null? rest) acc)+278
((and (car rest) (pair? (car rest))+279
(eq? (web-client-record-kind (car rest)) 'element))+280
(loop (cdr rest) (cons (web-client-record-node (car rest)) acc)))+281
(else (loop (cdr rest) acc)))))+282
+283
;; Old top-level records not carried into the new set are being removed:+284
;; unregister their listener subtrees. Additive cleanup only -- it does not+285
;; change which nodes the diff keeps.+286
(define (web-client-unregister-dropped! old-records new-records)+287
(let ((kept (web-client-element-nodes new-records)))+288
(for-each+289
(lambda (old)+290
(when (and old (pair? old)+291
(eq? (web-client-record-kind old) 'element)+292
(not (memv (web-client-record-node old) kept)))+293
(web-client-unregister-record-tree! old)))+294
old-records)))+295
296
(define (web-client-apply-attrs node old-attrs new-attrs) 297
(for-each 298
(lambda (attr)@@ -178,6 +317,24 @@
317
(wasm-dom-remove-attribute node name)) 318
(else 319
(wasm-dom-set-attribute node name (web-client-value->string value))))))))+320
new-attrs)+321
;; on-*: listeners present in the old attrs but gone (or no longer a+322
;; procedure) in the new attrs: detach them.+323
(for-each+324
(lambda (attr)+325
(let ((type (web-client-event-attr-type attr)))+326
(when type+327
(let ((new-attr (web-client-find-attr new-attrs (car attr))))+328
(when (not (and new-attr (web-client-event-attr-type new-attr)))+329
(web-client-listener-remove! node type))))))+330
old-attrs)+331
;; on-*: listeners in the new attrs: register the node + type once and keep+332
;; the closure current.+333
(for-each+334
(lambda (attr)+335
(let ((type (web-client-event-attr-type attr)))+336
(when type+337
(web-client-listener-set! node type (web-client-attribute-value attr))))) 338
new-attrs)) 339
340
(define (web-client-text-value value)@@ -261,7 +418,11 @@
418
(web-client-apply-attrs node old-attrs new-attrs) 419
(list 'element node tag new-attrs children))) 420
(else (web-client-create-node value)))−264
(web-client-create-node value)))+421
(begin+422
;; Incompatible: the old node (and its subtree) is being replaced --+423
;; drop its listeners before the new node supplants it.+424
(web-client-unregister-record-tree! old-record)+425
(web-client-create-node value)))) 426
427
(define (web-client-patch-children parent old-records new-values) 428
(let loop ((rest (web-client-normalize-children new-values))@@ -269,6 +430,7 @@
430
(records '())) 431
(if (null? rest) 432
(let ((ordered (reverse records)))+433
(web-client-unregister-dropped! old-records ordered) 434
(wasm-dom-replace-children parent) 435
(for-each 436
(lambda (record)