add WASM blazor-like thing
This commit is contained in:
77
go/wasmruntime/fetch.go
Normal file
77
go/wasmruntime/fetch.go
Normal file
@@ -0,0 +1,77 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package wasmruntime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
// Fetch performs an HTTP GET via the browser Fetch API and returns the response
|
||||
// body as a string. It bridges a JS Promise into Go: it registers then/catch
|
||||
// callbacks and blocks the calling goroutine on a channel until the request
|
||||
// settles.
|
||||
//
|
||||
// Because it blocks, call it from a goroutine — never from a reactive callback
|
||||
// or event handler — and write the result into signals when it returns, e.g.:
|
||||
//
|
||||
// go func() {
|
||||
// body, err := Fetch(url)
|
||||
// ...
|
||||
// data.Set(...) // updates the UI reactively
|
||||
// }()
|
||||
func Fetch(url string) (string, error) {
|
||||
var (
|
||||
once sync.Once
|
||||
done = make(chan struct{})
|
||||
body string
|
||||
ferr error
|
||||
onResp, onText, onErr js.Func
|
||||
)
|
||||
finish := func() { once.Do(func() { close(done) }) }
|
||||
|
||||
onErr = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
msg := "fetch error"
|
||||
if len(args) > 0 && args[0].Truthy() {
|
||||
e := args[0]
|
||||
if m := e.Get("message"); m.Truthy() {
|
||||
msg = m.String()
|
||||
} else {
|
||||
msg = e.Call("toString").String()
|
||||
}
|
||||
}
|
||||
ferr = errors.New(msg)
|
||||
finish()
|
||||
return nil
|
||||
})
|
||||
|
||||
onText = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) > 0 {
|
||||
body = args[0].String()
|
||||
}
|
||||
finish()
|
||||
return nil
|
||||
})
|
||||
|
||||
onResp = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
resp := args[0]
|
||||
if !resp.Get("ok").Bool() {
|
||||
ferr = fmt.Errorf("HTTP %d", resp.Get("status").Int())
|
||||
finish()
|
||||
return nil
|
||||
}
|
||||
// resp.text() -> Promise<string>
|
||||
resp.Call("text").Call("then", onText).Call("catch", onErr)
|
||||
return nil
|
||||
})
|
||||
|
||||
js.Global().Call("fetch", url).Call("then", onResp).Call("catch", onErr)
|
||||
|
||||
<-done
|
||||
onResp.Release()
|
||||
onText.Release()
|
||||
onErr.Release()
|
||||
return body, ferr
|
||||
}
|
||||
86
go/wasmruntime/mount.go
Normal file
86
go/wasmruntime/mount.go
Normal file
@@ -0,0 +1,86 @@
|
||||
//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
|
||||
}
|
||||
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
|
||||
}
|
||||
finish(root)
|
||||
}
|
||||
|
||||
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
|
||||
}))
|
||||
select {} // keep the runtime alive for event callbacks
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
68
go/wasmruntime/persist_wasm.go
Normal file
68
go/wasmruntime/persist_wasm.go
Normal file
@@ -0,0 +1,68 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package wasmruntime
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// State preservation across an in-place hot swap (see the dev server's
|
||||
// livereload script). A fresh wasm instance has fresh Go memory, so signal
|
||||
// values would reset on every rebuild. To keep them, the outgoing instance
|
||||
// snapshots its signals (by creation order, via a vdom.Collector) into
|
||||
// sessionStorage on dispose, and the incoming instance restores them on boot.
|
||||
//
|
||||
// It's best-effort: if an edit adds/removes/reorders signals, the by-order match
|
||||
// shifts and unmatched signals fall back to their initial value — the same
|
||||
// hook-order caveat as server components.
|
||||
|
||||
const stateKey = "__gowasm_hmr_state"
|
||||
|
||||
var stateCollector *vdom.Collector
|
||||
|
||||
// PreserveState registers the collector whose signals are snapshotted to
|
||||
// sessionStorage when this instance is disposed for a hot swap.
|
||||
func PreserveState(c *vdom.Collector) { stateCollector = c }
|
||||
|
||||
// RestoreState returns the signal snapshot saved by a prior instance before a
|
||||
// hot swap, or nil if there is none. It is one-shot: the entry is cleared so a
|
||||
// manual page refresh starts from initial state.
|
||||
func RestoreState() [][]byte {
|
||||
ss := js.Global().Get("sessionStorage")
|
||||
if !ss.Truthy() {
|
||||
return nil
|
||||
}
|
||||
raw := ss.Call("getItem", stateKey)
|
||||
if !raw.Truthy() {
|
||||
return nil
|
||||
}
|
||||
ss.Call("removeItem", stateKey)
|
||||
arr := js.Global().Get("JSON").Call("parse", raw)
|
||||
if arr.Type() != js.TypeObject {
|
||||
return nil
|
||||
}
|
||||
out := make([][]byte, arr.Length())
|
||||
for i := range out {
|
||||
out[i] = []byte(arr.Index(i).String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// saveState writes the preserved collector's current signal values to
|
||||
// sessionStorage. Called from the dispose hook before the DOM is torn down.
|
||||
func saveState() {
|
||||
if stateCollector == nil {
|
||||
return
|
||||
}
|
||||
ss := js.Global().Get("sessionStorage")
|
||||
if !ss.Truthy() {
|
||||
return
|
||||
}
|
||||
arr := js.Global().Get("Array").New()
|
||||
for _, b := range stateCollector.Snapshot() {
|
||||
arr.Call("push", string(b))
|
||||
}
|
||||
ss.Call("setItem", stateKey, js.Global().Get("JSON").Call("stringify", arr))
|
||||
}
|
||||
228
go/wasmruntime/reconcile.go
Normal file
228
go/wasmruntime/reconcile.go
Normal file
@@ -0,0 +1,228 @@
|
||||
//go:build js && wasm
|
||||
|
||||
// Package wasmruntime is the wasm client runtime for the neutral vdom package: it
|
||||
// reconciles vdom.VNode trees into the real DOM (fresh mount or hydration of
|
||||
// server-rendered DOM), and drives re-renders when signals change.
|
||||
package wasmruntime
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
var document = js.Global().Get("document")
|
||||
|
||||
// nodeRT is the per-node bookkeeping stored in VNode.Runtime (wasm only).
|
||||
type nodeRT struct {
|
||||
dom js.Value
|
||||
jsFuncs map[string]js.Func
|
||||
refs map[string]*handlerRef
|
||||
}
|
||||
|
||||
type handlerRef struct{ fn func(vdom.Event) }
|
||||
|
||||
func rt(n *vdom.VNode) *nodeRT {
|
||||
if n.Runtime == nil {
|
||||
n.Runtime = &nodeRT{jsFuncs: map[string]js.Func{}, refs: map[string]*handlerRef{}}
|
||||
}
|
||||
return n.Runtime.(*nodeRT)
|
||||
}
|
||||
|
||||
// clientEvent adapts a DOM event to vdom.Event.
|
||||
type clientEvent struct{ js js.Value }
|
||||
|
||||
func (e clientEvent) PreventDefault() { e.js.Call("preventDefault") }
|
||||
|
||||
func (e clientEvent) Value() string {
|
||||
t := e.js.Get("target")
|
||||
if !t.Truthy() {
|
||||
return ""
|
||||
}
|
||||
v := t.Get("value")
|
||||
if !v.Truthy() {
|
||||
return "" // e.g. a button has no value
|
||||
}
|
||||
return v.String()
|
||||
}
|
||||
|
||||
func one(n *vdom.VNode) []*vdom.VNode {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return []*vdom.VNode{n}
|
||||
}
|
||||
|
||||
// ---- fresh create + diff ----
|
||||
|
||||
func createDOM(n *vdom.VNode) js.Value {
|
||||
if n.Tag == "" {
|
||||
d := document.Call("createTextNode", n.Text)
|
||||
rt(n).dom = d
|
||||
return d
|
||||
}
|
||||
el := document.Call("createElement", n.Tag)
|
||||
rt(n).dom = el
|
||||
for k, v := range n.Attrs {
|
||||
el.Call("setAttribute", k, v)
|
||||
}
|
||||
for k, v := range n.Props {
|
||||
el.Set(k, v)
|
||||
}
|
||||
for name, h := range n.Events {
|
||||
addListener(n, name, h)
|
||||
}
|
||||
if n.HTML != "" {
|
||||
el.Set("innerHTML", n.HTML)
|
||||
return el
|
||||
}
|
||||
for _, c := range n.Children {
|
||||
el.Call("appendChild", createDOM(c))
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
func patchChildren(parent js.Value, old, next []*vdom.VNode) {
|
||||
n := max(len(old), len(next))
|
||||
for i := range n {
|
||||
var o, x *vdom.VNode
|
||||
if i < len(old) {
|
||||
o = old[i]
|
||||
}
|
||||
if i < len(next) {
|
||||
x = next[i]
|
||||
}
|
||||
patch(parent, o, x)
|
||||
}
|
||||
}
|
||||
|
||||
func patch(parent js.Value, o, x *vdom.VNode) {
|
||||
switch {
|
||||
case o == nil && x == nil:
|
||||
return
|
||||
case o == nil:
|
||||
parent.Call("appendChild", createDOM(x))
|
||||
case x == nil:
|
||||
parent.Call("removeChild", rt(o).dom)
|
||||
release(o)
|
||||
case o.Tag != x.Tag:
|
||||
parent.Call("replaceChild", createDOM(x), rt(o).dom)
|
||||
release(o)
|
||||
default:
|
||||
x.Runtime = o.Runtime // adopt dom + listeners
|
||||
dom := rt(x).dom
|
||||
if x.Tag == "" {
|
||||
if x.Text != o.Text {
|
||||
dom.Set("nodeValue", x.Text)
|
||||
}
|
||||
return
|
||||
}
|
||||
updateAttrs(o, x)
|
||||
updateProps(o, x)
|
||||
updateEvents(o, x)
|
||||
if x.HTML != "" {
|
||||
if x.HTML != o.HTML {
|
||||
dom.Set("innerHTML", x.HTML)
|
||||
}
|
||||
return
|
||||
}
|
||||
patchChildren(dom, o.Children, x.Children)
|
||||
}
|
||||
}
|
||||
|
||||
func updateAttrs(o, x *vdom.VNode) {
|
||||
dom := rt(x).dom
|
||||
for k := range o.Attrs {
|
||||
if _, ok := x.Attrs[k]; !ok {
|
||||
dom.Call("removeAttribute", k)
|
||||
}
|
||||
}
|
||||
for k, v := range x.Attrs {
|
||||
if o.Attrs[k] != v {
|
||||
dom.Call("setAttribute", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateProps(o, x *vdom.VNode) {
|
||||
dom := rt(x).dom
|
||||
for k, v := range x.Props {
|
||||
if o.Props[k] != v && dom.Get(k).String() != v {
|
||||
dom.Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateEvents(o, x *vdom.VNode) {
|
||||
r := rt(x) // same nodeRT as o (adopted above)
|
||||
for name, fn := range r.jsFuncs {
|
||||
if _, ok := x.Events[name]; !ok {
|
||||
r.dom.Call("removeEventListener", name, fn)
|
||||
fn.Release()
|
||||
delete(r.jsFuncs, name)
|
||||
delete(r.refs, name)
|
||||
}
|
||||
}
|
||||
for name, h := range x.Events {
|
||||
if ref, ok := r.refs[name]; ok {
|
||||
ref.fn = h
|
||||
} else {
|
||||
addListener(x, name, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addListener(n *vdom.VNode, name string, handler func(vdom.Event)) {
|
||||
r := rt(n)
|
||||
ref := &handlerRef{fn: handler}
|
||||
fn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
var ev js.Value
|
||||
if len(args) > 0 {
|
||||
ev = args[0]
|
||||
}
|
||||
ref.fn(clientEvent{js: ev})
|
||||
return nil
|
||||
})
|
||||
r.dom.Call("addEventListener", name, fn)
|
||||
r.jsFuncs[name] = fn
|
||||
r.refs[name] = ref
|
||||
}
|
||||
|
||||
func release(n *vdom.VNode) {
|
||||
if n.Runtime != nil {
|
||||
for _, fn := range rt(n).jsFuncs {
|
||||
fn.Release()
|
||||
}
|
||||
}
|
||||
for _, c := range n.Children {
|
||||
release(c)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- hydration: adopt server-rendered DOM instead of creating it ----
|
||||
|
||||
func hydrateNode(dom js.Value, n *vdom.VNode) {
|
||||
if !dom.Truthy() {
|
||||
return // structural mismatch; leave a hole (a later re-render will fix)
|
||||
}
|
||||
rt(n).dom = dom
|
||||
if n.Tag == "" {
|
||||
if dom.Get("nodeValue").String() != n.Text {
|
||||
dom.Set("nodeValue", n.Text)
|
||||
}
|
||||
return
|
||||
}
|
||||
for name, h := range n.Events {
|
||||
addListener(n, name, h)
|
||||
}
|
||||
for k, v := range n.Props {
|
||||
dom.Set(k, v)
|
||||
}
|
||||
if n.HTML != "" {
|
||||
return // trust server-rendered HTML
|
||||
}
|
||||
childNodes := dom.Get("childNodes")
|
||||
for i, c := range n.Children {
|
||||
hydrateNode(childNodes.Index(i), c)
|
||||
}
|
||||
}
|
||||
38
go/wasmruntime/router.go
Normal file
38
go/wasmruntime/router.go
Normal file
@@ -0,0 +1,38 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package wasmruntime
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// Router holds the current path in a signal, so reading Path() during render
|
||||
// re-renders on navigation (and browser back/forward).
|
||||
type Router struct {
|
||||
path *vdom.Signal[string]
|
||||
}
|
||||
|
||||
func NewRouter() *Router {
|
||||
r := &Router{path: vdom.NewSignal(currentPath())}
|
||||
popstate := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
r.path.Set(currentPath())
|
||||
return nil
|
||||
})
|
||||
js.Global().Call("addEventListener", "popstate", popstate)
|
||||
return r
|
||||
}
|
||||
|
||||
func currentPath() string {
|
||||
return js.Global().Get("location").Get("pathname").String()
|
||||
}
|
||||
|
||||
// Path returns the current route (reactive when read during render).
|
||||
func (r *Router) Path() string { return r.path.Get() }
|
||||
|
||||
// Navigate pushes a history entry and re-renders (client-side SPA navigation).
|
||||
func (r *Router) Navigate(path string) {
|
||||
js.Global().Get("history").Call("pushState", nil, "", path)
|
||||
r.path.Set(path)
|
||||
}
|
||||
7
go/wasmruntime/wasmruntime.go
Normal file
7
go/wasmruntime/wasmruntime.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !(js && wasm)
|
||||
|
||||
// Package wasmruntime is the browser-side client runtime (reconciler, mount /
|
||||
// hydrate, router, fetch, hot-reload state preservation). Its implementation
|
||||
// compiles only under GOOS=js GOARCH=wasm; this placeholder keeps the package
|
||||
// non-empty on other platforms so `go build ./...` / `go vet ./...` succeed.
|
||||
package wasmruntime
|
||||
Reference in New Issue
Block a user