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");