Commitd80af1bbRecorded7 Aug 2026Repositorysigil-web-client
web-client: refuse updates after DOM drift
Changed
README.md | 4 +++-
src/sigil/web/client.sgl | 33 +++++++++++++++++++++++++--------
test/integration/apps/web-client-drift/package.sgl | 32 ++++++++++++++++++++++++++++++++
test/integration/apps/web-client-drift/src/web-client-drift/main.sgl | 23 +++++++++++++++++++++++
test/integration/test-web-client-drift.mjs | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 195 insertions(+), 9 deletions(-)Diff
README.mdmodified
@@ -28,7 +28,9 @@ Client-side WASM UI framework for [Sigil](https://codeberg.org/sigil/sigil). A r
28
- `web-client-root?` — validate a root node handle 29
- `web-client-render` — render a view (an SXML value, or a nullary procedure returning one) 30
- `web-client-mount` — mount a view into a root−31
- `web-client-update` — update a mounted view in place+31
- `web-client-update` — update a mounted view in place; returns `#f` without+32
mutating when retained children no longer match the DOM (call+33
`web-client-clear` to recover from out-of-band changes) 34
- `web-client-clear` — clear a root 35
- `web-client-reset!` — reset retained client state 36
src/sigil/web/client.sglmodified
@@ -20,6 +20,8 @@
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
;; Exported for tests: verifies retained child handles against the DOM.+24
web-client-children-intact? 25
;; Exported for tests: the SXML child-content normalizer. Not part of 26
;; the stable public API. 27
web-client-normalize-children)@@ -464,6 +466,17 @@
466
(cdr target) 467
(cons (cons node ref) ops))))))) 468
+469
;; The retained records are only a valid reconciliation base while their+470
;; top-level nodes are still exactly the parent's real children. Checking+471
;; the first absent index pins the length as well as identity and order.+472
(define (web-client-children-intact? parent records)+473
(let loop ((rest records) (index 0))+474
(if (null? rest)+475
(wasm-dom-child-at? parent index #f)+476
(and (wasm-dom-child-at? parent index+477
(web-client-record-node (car rest)))+478
(loop (cdr rest) (+ index 1))))))+479
480
;; Reconcile parent's real child list with the patched records WITHOUT 481
;; detaching children that are already in place. Detaching the focused 482
;; element blurs it (silently -- no focusout fires), and the old@@ -527,16 +540,20 @@
540
(define (web-client-update selector view) 541
(let ((root (web-client-root selector))) 542
(if root−530
(let* ((value (web-client-render view))−531
(entry (web-client-find-root selector))+543
(let* ((entry (web-client-find-root selector)) 544
(old-root-record (if entry (cdr entry) #f))−533
(old-children (if (and old-root-record−534
(= root (web-client-root-record-node old-root-record)))+545
(same-root? (and old-root-record+546
(= root (web-client-root-record-node old-root-record))))+547
(old-children (if same-root? 548
(web-client-root-record-children old-root-record)−536
'()))−537
(children (web-client-patch-children root old-children value)))−538
(web-client-store-root! selector (list 'root root children))−539
#t)+549
'())))+550
(if (and same-root?+551
(not (web-client-children-intact? root old-children)))+552
#f+553
(let* ((value (web-client-render view))+554
(children (web-client-patch-children root old-children value)))+555
(web-client-store-root! selector (list 'root root children))+556
#t))) 557
#f))) 558
559
(define (web-client-clear selector)test/integration/apps/web-client-drift/package.sgladded
@@ -0,0 +1,32 @@
+1
(package+2
name: "web-client-drift"+3
version: "0.1.0"+4
sigil: "^0.18"+5
entry: '(web-client-drift main)+6
+7
configs: (list+8
(config+9
name: 'web+10
output-dir: "build/web"+11
toolchain: 'zig+12
target: "wasm32-wasi"+13
backend: 'native+14
debug?: #f+15
optimize: 1+16
bundle?: #f+17
c-flags: (with-sigil-c-flags '("-std=c99" "-D_GNU_SOURCE" "-D__SIGIL_WASM__" "-O1"))+18
link-flags: '("-lc")+19
features: '(web wasm)))+20
+21
dependencies: (list+22
(from-path dir: "../../../.." package: "sigil-web-client")+23
(from-git url: "codeberg:sigil/sigil" package: "sigil-lib" version: "^0.18")+24
(from-git url: "codeberg:sigil/sigil" package: "sigil-wasm-runtime" version: "^0.18")+25
(from-git url: "codeberg:sigil/sigil" package: "sigil-wasm-dom" version: "^0.18"))+26
+27
tasks: (list+28
(task+29
name: 'build+30
steps: (list+31
(compile-sigil-modules sources: "src/**/*.sgl")+32
(link-web-application name: "web-client-drift")))))test/integration/apps/web-client-drift/src/web-client-drift/main.sgladded
@@ -0,0 +1,23 @@
+1
(define-library (web-client-drift main)+2
(import (sigil core)+3
(sigil wasm dom)+4
(sigil web client))+5
+6
(export main)+7
+8
(begin+9
(define (main)+10
(let ((status (wasm-dom-query-selector "#p5-status"))+11
(marker (wasm-dom-create-element "div")))+12
(let* ((fresh (web-client-update "#p5-root" '(div (@ (class "p5-child")) "one")))+13
(undrifted (web-client-update "#p5-root" '(div (@ (class "p5-child")) "two")))+14
(root (wasm-dom-query-selector "#p5-root"))+15
(child (wasm-dom-query-selector ".p5-child"))+16
(removed (wasm-dom-remove-child root child))+17
(drifted (web-client-update "#p5-root"+18
'(div (@ (class "p5-replacement")) "three")))+19
(unmutated (not (wasm-dom-query-selector ".p5-replacement"))))+20
(when (and fresh undrifted removed (not drifted) unmutated)+21
(wasm-dom-append-child status marker))))+22
(display "PASS web-client-drift")+23
(newline))))test/integration/test-web-client-drift.mjsadded
@@ -0,0 +1,112 @@
+1
#!/usr/bin/env node+2
+3
import fs from "node:fs";+4
import path from "node:path";+5
import { spawnSync } from "node:child_process";+6
import { fileURLToPath } from "node:url";+7
import { WASI } from "node:wasi";+8
+9
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");+10
const app = path.join(repo, "test/integration/apps/web-client-drift");+11
const build = path.join(app, "build/web");+12
const sigil = process.env.SIGIL;+13
const redirects = process.env.SIGIL_REDIRECTS;+14
if (!sigil || !redirects) throw new Error("set SIGIL and SIGIL_REDIRECTS");+15
+16
const built = process.env.P5_SKIP_BUILD === "1" ? { status: 0, stdout: "", stderr: "" } : spawnSync(sigil, ["build", "--config", "web", "--force", "--no-bundle", "--redirects", redirects], {+17
cwd: app,+18
encoding: "utf8",+19
env: { ...process.env, SIGIL_BACKEND: "native" },+20
});+21
process.stdout.write(built.stdout || "");+22
process.stderr.write(built.stderr || "");+23
if (built.status !== 0) throw new Error(`fixture build failed (${built.status})`);+24
+25
class Node {+26
constructor(tag = "") {+27
this.tagName = tag.toUpperCase();+28
this.childNodes = [];+29
this.parentNode = null;+30
this.attributes = new Map();+31
this.className = "";+32
}+33
appendChild(child) { return this.insertBefore(child, null); }+34
insertBefore(child, ref) {+35
if (child.parentNode) child.parentNode.removeChild(child);+36
const index = ref === null ? this.childNodes.length : this.childNodes.indexOf(ref);+37
if (index < 0) throw new Error("reference is not a child");+38
this.childNodes.splice(index, 0, child);+39
child.parentNode = this;+40
return child;+41
}+42
removeChild(child) {+43
const index = this.childNodes.indexOf(child);+44
if (index < 0) throw new Error("node is not a child");+45
this.childNodes.splice(index, 1);+46
child.parentNode = null;+47
return child;+48
}+49
replaceChildren(...children) {+50
for (const child of [...this.childNodes]) this.removeChild(child);+51
for (const child of children) this.appendChild(child);+52
}+53
setAttribute(name, value) {+54
this.attributes.set(name, String(value));+55
if (name === "class") this.className = String(value);+56
}+57
removeAttribute(name) { this.attributes.delete(name); }+58
addEventListener() {}+59
removeEventListener() {}+60
}+61
+62
const root = new Node("div");+63
const statusRoot = new Node("div");+64
function walk(node, predicate) {+65
if (predicate(node)) return node;+66
for (const child of node.childNodes) {+67
const found = walk(child, predicate);+68
if (found) return found;+69
}+70
return null;+71
}+72
globalThis.document = {+73
querySelector(selector) {+74
if (selector === "#p5-root") return root;+75
if (selector === "#p5-status") return statusRoot;+76
if (selector === ".p5-child" || selector === ".p5-replacement") {+77
return root.childNodes[0] || null;+78
}+79
if (selector.startsWith(".")) {+80
const wanted = selector.slice(1);+81
return walk(root, node => node.className.split(/\s+/).includes(wanted)) ||+82
walk(statusRoot, node => node.className.split(/\s+/).includes(wanted));+83
}+84
return null;+85
},+86
createElement(tag) { return new Node(tag); },+87
createTextNode(text) { const node = new Node(); node.textContent = String(text); return node; },+88
};+89
+90
await import(path.join(build, "assets/sigil-wasm-dom.js"));+91
const module = await WebAssembly.compile(fs.readFileSync(path.join(build, "web-client-drift.wasm")));+92
const wasi = new WASI({ version: "preview1" });+93
const imports = {+94
...wasi.getImportObject(),+95
...SigilWasmDom.createImports(),+96
};+97
for (const entry of WebAssembly.Module.imports(module)) {+98
imports[entry.module] ||= {};+99
if (!(entry.name in imports[entry.module]) && entry.kind === "function") {+100
imports[entry.module][entry.name] = () => 0;+101
}+102
}+103
const instance = await WebAssembly.instantiate(module, imports);+104
SigilWasmDom.setInstance(instance);+105
wasi.start(instance);+106
const init = instance.exports.sigil_wasm_init();+107
if (init !== 0) throw new Error(`sigil_wasm_init returned ${init}`);+108
const start = instance.exports.sigil_wasm_start();+109
if (start !== 0) throw new Error(`sigil_wasm_start returned ${start}`);+110
if (root.childNodes.length !== 0) throw new Error("refused drift render mutated the root");+111
if (statusRoot.childNodes.length !== 1) throw new Error("fixture did not reach its completion marker");+112
console.log("P5-GATE: PASS fresh=#t undrifted=#t drifted=#f mutated=no");