diff --git a/go/cmd/examples/go-wasm-web/README.md b/go/cmd/examples/go-wasm-web/README.md index 8655c063..6f569601 100644 --- a/go/cmd/examples/go-wasm-web/README.md +++ b/go/cmd/examples/go-wasm-web/README.md @@ -27,8 +27,11 @@ answers `/api/quotes` with `httputil.RespondGob([]Quote)` and the client decodes it straight back into `[]Quote` with `httputil.FetchGob` (the same Go type on both ends — no JSON); and a **user-entered** GitHub repo (`owner/name`) is fetched with `httputil.FetchJSON` into a tagged Go struct. The client HTTP -transport is `wasmruntime.FetchBytes`, injected once via -`httputil.SetClientTransport`. +transport is `wasmruntime.FetchBytes`, installed by the runtime itself (override +it with `httputil.SetClientTransport` for auth headers or a base URL). `/data` is +a `static` route, and fetching only exists on the client, so the fetches no-op +during SSR: the server pre-renders the page's **spinner**, and the client runs +them for real after hydration. Styling is **Tailwind**: the dev server (and `build.sh`) run `kjol/cmd/twcss`, which scans the Go markup + the `webui` kit for utility classes and compiles diff --git a/go/cmd/examples/go-wasm-web/app/data.go b/go/cmd/examples/go-wasm-web/app/data.go index 553960bf..45fa2311 100644 --- a/go/cmd/examples/go-wasm-web/app/data.go +++ b/go/cmd/examples/go-wasm-web/app/data.go @@ -25,7 +25,7 @@ type repoInfo struct { Stars int `json:"stargazers_count"` } -//gowasm:page /data layout=app +//gowasm:page /data layout=app static func DataPage(d Deps) func() *VNode { // (1) gob from our own server via httputil.RespondGob / FetchGob. quotes := NewSignal([]Quote{}) diff --git a/go/cmd/examples/go-wasm-web/app/routes.gen.go b/go/cmd/examples/go-wasm-web/app/routes.gen.go index de63ba83..60f9cf8c 100644 --- a/go/cmd/examples/go-wasm-web/app/routes.gen.go +++ b/go/cmd/examples/go-wasm-web/app/routes.gen.go @@ -20,6 +20,7 @@ var StaticPaths = map[string]bool{ "/": true, "/about": true, "/chart": true, + "/data": true, } // RouteLayout maps each route to the name of the layout that wraps it. diff --git a/go/cmd/examples/go-wasm-web/wasm/main.go b/go/cmd/examples/go-wasm-web/wasm/main.go index a1216992..9090fd2d 100644 --- a/go/cmd/examples/go-wasm-web/wasm/main.go +++ b/go/cmd/examples/go-wasm-web/wasm/main.go @@ -6,17 +6,19 @@ package main import ( "gowasmweb/app" - "kjol/httputil" "kjol/vdom" "kjol/wasmruntime" ) func main() { + // (The client transport for httputil.FetchGob / FetchJSON needs no wiring — + // importing wasmruntime installs it. On the server it stays nil, so those + // fetches no-op during SSR and the page ships its loading state.) + // // Collect the signals created during setup so their values can be preserved // across an in-place hot swap. On a normal load RestoreState is nil (fresh // state); after a dev hot-reload it carries the previous instance's values, // which NewSignal restores by creation order. - httputil.SetClientTransport(wasmruntime.FetchBytes) // typed gob/json fetches for app pages collector := vdom.BeginCollect(wasmruntime.RestoreState()) router := wasmruntime.NewRouter() deps := app.Deps{Path: router.Path, Navigate: router.Navigate} diff --git a/go/httputil/fetch.go b/go/httputil/fetch.go index 94d459c3..8af3828a 100644 --- a/go/httputil/fetch.go +++ b/go/httputil/fetch.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/gob" "encoding/json" - "errors" ) // Client-side fetch helpers — the counterparts to RespondGob / RespondJSON. In a @@ -12,24 +11,33 @@ import ( // the server encodes with RespondGob(data) and the client decodes straight back // into that type with FetchGob — no JSON, no hand-written unmarshalling. These // are neutral (they only touch encoding/*), so app code can call them while -// staying SSR-able; the actual HTTP transport is client-only and injected at +// staying SSR-able; the actual HTTP transport is client-only and installed at // startup by the wasm runtime (SetClientTransport), the inversion vdom.Schedule // uses. -// clientTransport does an HTTP GET returning the raw body; nil on the server. +// clientTransport does an HTTP GET returning the raw body. The wasm runtime +// installs it in its init, so it is always set in the browser and always nil +// everywhere else — "nil transport" therefore means "not the client", which is +// what makes the SSR behavior in fetchInto safe. var clientTransport func(url string) ([]byte, error) // SetClientTransport installs the HTTP GET used by FetchGob / FetchJSON. The -// wasm client runtime calls this at startup with wasmruntime.FetchBytes. +// wasm client runtime installs wasmruntime.FetchBytes automatically; call this +// only to wrap it (auth headers, a base URL, a test double). func SetClientTransport(get func(url string) ([]byte, error)) { clientTransport = get } func fetchInto[T any](url string, cb func(T, error), decode func([]byte, *T) error) { + // Off the client (SSR of a static page, a native build) there is no transport + // and no browser to fetch with, so the request simply never happens: cb is not + // called, and the caller stays in the loading state it started in. That is + // what SSR should ship — a spinner or skeleton, not a "fetch failed" error the + // server was never able to avoid. The client re-runs the fetch for real on its + // first render after hydration. + if clientTransport == nil { + return + } go func() { var out T - if clientTransport == nil { - cb(out, errors.New("httputil: no client transport installed (client-only)")) - return - } body, err := clientTransport(url) if err != nil { cb(out, err) @@ -51,6 +59,10 @@ func fetchInto[T any](url string, cb func(T, error), decode func([]byte, *T) err // httputil.FetchGob("/api/quotes", func(qs []Quote, err error) { // if err != nil { /* … */ } else { quotes.Set(qs) } // }) +// +// Fetching is client-only: during SSR this does nothing and cb is never called, +// so a page that starts out loading renders its spinner into the static HTML and +// fetches for real once the client takes over. func FetchGob[T any](url string, cb func(T, error)) { fetchInto(url, cb, func(b []byte, out *T) error { return gob.NewDecoder(bytes.NewReader(b)).Decode(out) diff --git a/go/httputil/fetch_test.go b/go/httputil/fetch_test.go new file mode 100644 index 00000000..dbb91125 --- /dev/null +++ b/go/httputil/fetch_test.go @@ -0,0 +1,58 @@ +package httputil + +import ( + "bytes" + "encoding/gob" + "testing" + "time" +) + +type quote struct{ Author, Text string } + +// With no transport installed we are not on the client, so the fetch must not +// happen and the callback must not fire — that is what leaves an SSR'd page in +// its loading state (spinner/skeleton) rather than rendering a fetch error the +// server had no way to avoid. +func TestFetchWithoutTransportDoesNotCallBack(t *testing.T) { + defer SetClientTransport(nil) + SetClientTransport(nil) + + called := make(chan error, 1) + FetchGob("/api/quotes", func(_ []quote, err error) { called <- err }) + FetchJSON("/api/quotes", func(_ []quote, err error) { called <- err }) + + select { + case err := <-called: + t.Fatalf("callback ran with no transport installed (err=%v); SSR would render an error instead of the loading state", err) + case <-time.After(50 * time.Millisecond): + } +} + +// The client path is unchanged: with a transport installed the body is fetched +// and decoded into the same Go type the server encoded. +func TestFetchGobWithTransport(t *testing.T) { + defer SetClientTransport(nil) + want := []quote{{Author: "Rob Pike", Text: "When in doubt, use brute force."}} + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(want); err != nil { + t.Fatal(err) + } + SetClientTransport(func(string) ([]byte, error) { return buf.Bytes(), nil }) + + got := make(chan []quote, 1) + FetchGob("/api/quotes", func(qs []quote, err error) { + if err != nil { + t.Errorf("FetchGob: %v", err) + } + got <- qs + }) + + select { + case qs := <-got: + if len(qs) != 1 || qs[0] != want[0] { + t.Fatalf("decoded %v, want %v", qs, want) + } + case <-time.After(time.Second): + t.Fatal("FetchGob callback never ran") + } +} diff --git a/go/wasmruntime/fetch.go b/go/wasmruntime/fetch.go index a3d37a96..58b404f9 100644 --- a/go/wasmruntime/fetch.go +++ b/go/wasmruntime/fetch.go @@ -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). diff --git a/go/wasmruntime/reconcile.go b/go/wasmruntime/reconcile.go index a66f714c..1fa19046 100644 --- a/go/wasmruntime/reconcile.go +++ b/go/wasmruntime/reconcile.go @@ -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) } } diff --git a/go/wasmruntime/reconcile_test.go b/go/wasmruntime/reconcile_test.go new file mode 100644 index 00000000..f434c706 --- /dev/null +++ b/go/wasmruntime/reconcile_test.go @@ -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(``)), + ) + patchChildren(root, nil, one(chart)) + if got := root.Get("innerHTML").String(); !strings.Contains(got, ``) { + 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(``)) + 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, ``) { + 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(``)) + patchChildren(root, nil, one(a)) + before := rt(a).dom.Get("childNodes").Index(0) + + b := vdom.El("div", vdom.Raw(``)) + 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") + } +} diff --git a/go/wasmruntime/testdata/domexec.js b/go/wasmruntime/testdata/domexec.js new file mode 100644 index 00000000..abf58159 --- /dev/null +++ b/go/wasmruntime/testdata/domexec.js @@ -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("")}`; +} + +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); +});