//go:build js && wasm package wasmruntime import ( "syscall/js" "kjol/vdom" ) var ( rootRender func() renderScheduled bool disposed bool flushFunc js.Func flushInited bool ) // HasServerContent reports whether #app already holds server-rendered markup // (so the client should Hydrate rather than build from scratch). func HasServerContent() bool { return document.Call("getElementById", "app").Get("firstChild").Truthy() } // Run mounts a component fresh (client-side rendering). The component returns a // VNode tree; any signal write re-renders and reconciles into the DOM. func Run(component func() *vdom.VNode) { vdom.Schedule = scheduleRender root := document.Call("getElementById", "app") var prev *vdom.VNode rootRender = func() { next := component() patchChildren(root, one(prev), one(next)) prev = next flushAfterRender() // refs are live now — measurement callbacks can run } rootRender() finish(root) } // Hydrate attaches to server-rendered DOM: it renders the same initial tree the // server did, adopts the existing nodes (wiring events, no re-creation), then // re-renders reactively from there. func Hydrate(component func() *vdom.VNode) { vdom.Schedule = scheduleRender root := document.Call("getElementById", "app") prev := component() hydrateNode(root.Get("firstChild"), prev) rootRender = func() { next := component() patchChildren(root, one(prev), one(next)) prev = next flushAfterRender() } flushAfterRender() // the adopted DOM is live; refs from hydration are usable finish(root) } // finish wires the hot-swap teardown hook. It RETURNS — see Wait. func finish(root js.Value) { // Allow a JS loader to tear this instance down before an in-place hot swap: // snapshot signal state (so the next instance restores it), then clear #app. js.Global().Set("__gowasmDispose", js.FuncOf(func(this js.Value, args []js.Value) any { disposed = true saveState() root.Set("innerHTML", "") return nil })) } // Wait blocks forever, keeping the Go runtime alive so its event callbacks can fire. // // It is the LAST line of main, and it is not optional: a Go/wasm program whose main // returns is a dead program, and every handler you registered panics with "Go program has // already exited" the first time it is touched. // // This used to be the last line of Run and Hydrate instead — a `select {}` buried inside // them. That made both of them functions that never return, which is not what they look // like, and it quietly turned everything written after the mount call into dead code: // // wasmruntime.Hydrate(render) // app.Theme.Init() // never ran. Not once. // // The theme controller sat there for the life of the project not knowing what theme the // page was in, so the first click of the switch did nothing at all. Anything you have to // do AFTER the first render — adopt a stored preference, start a poller, measure the // page — hits the same wall. // // So: mounting mounts, and blocking is its own line, where you can see it. func Wait() { select {} } func scheduleRender() { if disposed || rootRender == nil || renderScheduled { return } renderScheduled = true js.Global().Call("queueMicrotask", ensureFlush()) } func ensureFlush() js.Func { if !flushInited { flushFunc = js.FuncOf(func(this js.Value, args []js.Value) any { renderScheduled = false rootRender() return nil }) flushInited = true } return flushFunc }