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, `