Update fetch for static page gen, fix DOM patching

This commit is contained in:
2026-07-13 09:51:32 -04:00
parent 9662f83319
commit ea3d2a6d03
10 changed files with 305 additions and 13 deletions

View File

@@ -8,8 +8,17 @@ import (
"strings"
"sync"
"syscall/js"
"kjol/httputil"
)
// Installing the transport here (rather than leaving it to each app's main)
// keeps httputil's invariant true by construction: FetchGob / FetchJSON have a
// transport exactly when they are running in the browser. That is what lets them
// no-op during SSR instead of reporting a failure. Apps that need custom headers
// or a base URL can wrap FetchBytes with httputil.SetClientTransport at startup.
func init() { httputil.SetClientTransport(FetchBytes) }
// absURL resolves a relative path against the current origin (a browser resolves
// relative fetch URLs itself, but building the absolute URL keeps behavior
// uniform — and lets non-browser hosts, e.g. tests, fetch too).

View File

@@ -129,10 +129,22 @@ func patch(parent js.Value, o, x *vdom.VNode) {
updateEvents(o, x)
if x.HTML != "" {
if x.HTML != o.HTML {
// innerHTML discards whatever DOM the old children owned, so their
// listeners go with it.
for _, c := range o.Children {
release(c)
}
dom.Set("innerHTML", x.HTML)
}
return
}
// o was raw HTML and x isn't. A Raw node keeps its markup only in the DOM
// (VNode.HTML, no Children), so patchChildren below would diff x's children
// against an empty list and *append* them after markup nothing will ever
// remove — e.g. /chart's SVG surviving a route change into /data. Clear it.
if o.HTML != "" {
dom.Set("innerHTML", "")
}
patchChildren(dom, o.Children, x.Children)
}
}

View File

@@ -0,0 +1,86 @@
//go:build js && wasm
package wasmruntime
import (
"strings"
"testing"
"kjol/vdom"
)
// The reconciler is wasm-only and needs a DOM, so these do not run under a plain
// `go test ./...` (the native build of this package is an empty placeholder).
// Run them against the minimal DOM in testdata/domexec.js — from kjol/go:
//
// GOOS=js GOARCH=wasm go test -exec="node testdata/domexec.js" ./wasmruntime
// A Raw() node carries its markup in VNode.HTML and has NO children — the markup
// only exists in the DOM. So when the diff reuses that element for a node that
// has children instead (same tag, same index — e.g. a route change from a page
// with a server-rendered SVG to one without), the new children must not simply be
// appended alongside the surviving markup.
func TestPatchClearsRawHTMLWhenElementIsReused(t *testing.T) {
root := document.Call("createElement", "div")
chart := vdom.El("div", vdom.Attr("class", "grid"),
vdom.El("div", vdom.Attr("class", "cell"), vdom.Raw(`<svg id="bar"></svg>`)),
)
patchChildren(root, nil, one(chart))
if got := root.Get("innerHTML").String(); !strings.Contains(got, `<svg id="bar">`) {
t.Fatalf("raw HTML was not mounted: %s", got)
}
// Same tags at the same indexes, so the diff adopts the DOM rather than
// replacing it.
data := vdom.El("div", vdom.Attr("class", "cards"),
vdom.El("div", vdom.Attr("class", "card"), vdom.Text("gob section")),
)
patchChildren(root, one(chart), one(data))
got := root.Get("innerHTML").String()
if strings.Contains(got, "svg") {
t.Errorf("stale raw HTML survived the patch (the chart would follow you to the next page):\n%s", got)
}
if !strings.Contains(got, "gob section") {
t.Errorf("new children were not rendered:\n%s", got)
}
}
// The reverse transition: an element with real children reused for a Raw() node.
// innerHTML replaces the children wholesale, so nothing may linger.
func TestPatchReplacesChildrenWithRawHTML(t *testing.T) {
root := document.Call("createElement", "div")
withKids := vdom.El("div", vdom.El("span", vdom.Text("hello")))
patchChildren(root, nil, one(withKids))
withRaw := vdom.El("div", vdom.Raw(`<svg id="pie"></svg>`))
patchChildren(root, one(withKids), one(withRaw))
got := root.Get("innerHTML").String()
if strings.Contains(got, "hello") || strings.Contains(got, "span") {
t.Errorf("old children survived under raw HTML:\n%s", got)
}
if !strings.Contains(got, `<svg id="pie">`) {
t.Errorf("raw HTML was not applied:\n%s", got)
}
}
// Raw markup that does not change must be left alone (it is not re-set on every
// render, which would blow away any DOM state inside it).
func TestPatchKeepsUnchangedRawHTML(t *testing.T) {
root := document.Call("createElement", "div")
a := vdom.El("div", vdom.Raw(`<svg id="bar"></svg>`))
patchChildren(root, nil, one(a))
before := rt(a).dom.Get("childNodes").Index(0)
b := vdom.El("div", vdom.Raw(`<svg id="bar"></svg>`))
patchChildren(root, one(a), one(b))
after := rt(b).dom.Get("childNodes").Index(0)
if !before.Equal(after) {
t.Error("unchanged raw HTML was re-parsed instead of being left in place")
}
}

109
go/wasmruntime/testdata/domexec.js vendored Normal file
View File

@@ -0,0 +1,109 @@
// A `go test -exec` wrapper for GOOS=js GOARCH=wasm that installs a minimal DOM
// before starting the Go runtime, so the reconciler can be driven headlessly
// under node instead of only in a browser. From kjol/go:
//
// GOOS=js GOARCH=wasm go test -exec="node testdata/domexec.js" ./wasmruntime
//
// The subtlety that matters: in a real browser, setting .innerHTML *parses* the
// markup into real child nodes, so a later appendChild lands after it. The shim
// models that (one opaque "#raw" child) rather than keeping innerHTML as a
// detached string — treating it as a string would hide the very class of bug
// these tests exist to catch (stale raw markup surviving a diff).
"use strict";
const { execSync } = require("child_process");
class DNode {
constructor(tag) {
this.tag = tag;
this.childNodes = [];
this.attrs = {};
this.parentNode = null;
this.listeners = {};
this.nodeValue = null;
this.rawHTML = null;
}
get firstChild() { return this.childNodes[0] ?? null; }
appendChild(c) { c.parentNode = this; this.childNodes.push(c); return c; }
removeChild(c) {
const i = this.childNodes.indexOf(c);
if (i < 0) throw new Error("removeChild: node is not a child");
this.childNodes.splice(i, 1);
c.parentNode = null;
return c;
}
replaceChild(next, old) {
const i = this.childNodes.indexOf(old);
if (i < 0) throw new Error("replaceChild: node is not a child");
this.childNodes[i] = next;
next.parentNode = this;
old.parentNode = null;
return old;
}
setAttribute(k, v) { this.attrs[k] = String(v); }
removeAttribute(k) { delete this.attrs[k]; }
addEventListener(name, fn) { (this.listeners[name] ??= []).push(fn); }
removeEventListener(name, fn) {
const l = this.listeners[name] ?? [];
const i = l.indexOf(fn);
if (i >= 0) l.splice(i, 1);
}
set innerHTML(html) {
for (const c of this.childNodes) c.parentNode = null;
this.childNodes = [];
if (html !== "") {
const raw = new DNode("#raw");
raw.rawHTML = html;
raw.parentNode = this;
this.childNodes.push(raw);
}
}
get innerHTML() { return this.childNodes.map(serialize).join(""); }
get outerHTML() { return serialize(this); }
}
function serialize(n) {
if (n.tag === "#text") return n.nodeValue ?? "";
if (n.tag === "#raw") return n.rawHTML ?? "";
const attrs = Object.keys(n.attrs).sort().map((k) => ` ${k}="${n.attrs[k]}"`).join("");
return `<${n.tag}${attrs}>${n.childNodes.map(serialize).join("")}</${n.tag}>`;
}
globalThis.document = {
createElement: (tag) => new DNode(tag),
createTextNode: (text) => { const n = new DNode("#text"); n.nodeValue = text; return n; },
getElementById: () => null,
};
// ---- go_js_wasm_exec boilerplate ----
globalThis.require = require;
globalThis.fs = require("fs");
globalThis.TextEncoder = require("util").TextEncoder;
globalThis.TextDecoder = require("util").TextDecoder;
globalThis.performance ??= require("performance");
globalThis.crypto ??= require("crypto");
// wasm_exec.js moved from misc/wasm to lib/wasm in Go 1.24.
const goroot = execSync("go env GOROOT").toString().trim();
try {
require(goroot + "/lib/wasm/wasm_exec.js");
} catch {
require(goroot + "/misc/wasm/wasm_exec.js");
}
const go = new Go();
go.argv = process.argv.slice(2);
go.env = Object.assign({ TMPDIR: require("os").tmpdir() }, process.env);
go.exit = process.exit;
WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then((result) => {
process.on("exit", (code) => {
if (code === 0 && !go.exited) {
go._pendingEvent = { id: 0 };
go._resume();
}
});
return go.run(result.instance);
}).catch((err) => {
console.error(err);
process.exit(1);
});