//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 // portal is the body-level container holding a TagPortal node's children. The // `dom` field is the hidden in-flow placeholder that marks its slot in the tree. portal js.Value } 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) StopPropagation() { e.js.Call("stopPropagation") } 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 (e clientEvent) Checked() bool { t := e.js.Get("target") if !t.Truthy() { return false } return t.Get("checked").Truthy() } func (e clientEvent) Key() string { k := e.js.Get("key") if !k.Truthy() { return "" } return k.String() } func (e clientEvent) ClientX() int { return coord(e.js, "clientX") } func (e clientEvent) ClientY() int { return coord(e.js, "clientY") } func coord(ev js.Value, prop string) int { v := ev.Get(prop) if v.Type() != js.TypeNumber { return 0 } return v.Int() } func (e clientEvent) Target() any { t := e.js.Get("target") if !t.Truthy() { return nil } return t } func (e clientEvent) SetData(format, data string) { if dt := e.js.Get("dataTransfer"); dt.Truthy() { dt.Call("setData", format, data) } } func (e clientEvent) GetData(format string) string { dt := e.js.Get("dataTransfer") if !dt.Truthy() { return "" } return dt.Call("getData", format).String() } func one(n *vdom.VNode) []*vdom.VNode { if n == nil { return nil } return []*vdom.VNode{n} } // ---- fresh create + diff ---- // SVG lives in its own XML namespace, and an element's namespace is fixed at // CREATION — you cannot fix it afterwards with an attribute. // // document.createElement("svg") does NOT make an SVG element; it makes an // HTMLUnknownElement that happens to be spelled "svg". It has no geometry, it // renders nothing, and its children are inert. Everything still *looks* right in the // DOM inspector, which is what makes this so easy to miss. // // The reason it only broke on NAVIGATION: a server-rendered page's icons come from // the HTML parser, which handles as foreign content and gets the namespace // right, and hydration merely adopts those nodes. Only elements the client CREATES — // i.e. everything rendered after the first paint — went through createElement and // came out inert. Hence: icons fine on load, gone as soon as you navigate. const svgNamespace = "http://www.w3.org/2000/svg" // elementNS returns the namespace an element and its subtree belong to. Nested // switches into the SVG namespace; switches back to HTML. func elementNS(tag, parentNS string) string { switch tag { case "svg": return svgNamespace case "foreignObject": return "" // HTML content inside SVG } return parentNS } func createElement(tag, ns string) js.Value { if ns == "" { return document.Call("createElement", tag) } return document.Call("createElementNS", ns, tag) } // createDOM builds a node. ns is the namespace inherited from the parent element: // "" for ordinary HTML, svgNamespace inside an . func createDOM(n *vdom.VNode, ns string) js.Value { if n.Tag == "" { d := document.Call("createTextNode", n.Text) rt(n).dom = d vdom.SetRefNode(n.Ref, d) return d } if n.Tag == vdom.TagPortal { return createPortal(n) } ns = elementNS(n.Tag, ns) el := createElement(n.Tag, ns) rt(n).dom = el vdom.SetRefNode(n.Ref, el) for k, v := range n.Attrs { el.Call("setAttribute", k, v) } for k, v := range n.Props { setProp(el, 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, ns)) } return el } // createPortal builds the two halves of a TagPortal node: a hidden placeholder // that holds its slot in the tree (so sibling indexes — and hydration — still line // up), and a container appended to document.body that the children actually render // into. Returning the placeholder is what keeps the caller's appendChild correct. func createPortal(n *vdom.VNode) js.Value { r := rt(n) placeholder := document.Call("createElement", "div") placeholder.Call("setAttribute", "data-portal", "") placeholder.Get("style").Call("setProperty", "display", "none") r.dom = placeholder r.portal = newPortalContainer() vdom.SetRefNode(n.Ref, r.portal) for _, c := range n.Children { // The container is a plain
on document.body, so its children start in // the HTML namespace however deeply the portal was nested. r.portal.Call("appendChild", createDOM(c, "")) } return placeholder } func newPortalContainer() js.Value { c := document.Call("createElement", "div") c.Call("setAttribute", "data-portal-container", "") document.Get("body").Call("appendChild", c) return c } // nsOf reads an existing element's namespace, so anything created beneath it // inherits the right one. Derived from the live DOM rather than threaded through the // call stack: the parent already knows the answer, and it cannot go stale. func nsOf(el js.Value) string { if !el.Truthy() { return "" } uri := el.Get("namespaceURI") if uri.Truthy() && uri.String() == svgNamespace { return svgNamespace } return "" // HTML (or a text node / detached node) } // patchChildren diffs a child list against `parent`'s children. func patchChildren(parent js.Value, old, next []*vdom.VNode) { ns := nsOf(parent) // read once: every child of this parent shares it 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, ns) } } func patch(parent js.Value, o, x *vdom.VNode, ns string) { switch { case o == nil && x == nil: return case o == nil: parent.Call("appendChild", createDOM(x, ns)) case x == nil: parent.Call("removeChild", rt(o).dom) release(o) case o.Tag != x.Tag: parent.Call("replaceChild", createDOM(x, ns), rt(o).dom) release(o) default: x.Runtime = o.Runtime // adopt dom + listeners dom := rt(x).dom if !dom.Truthy() { // o was a hydration hole (no adopted DOM node, e.g. a server/client // markup mismatch). Recreate this node fresh instead of calling into an // undefined DOM handle. parent.Call("appendChild", createDOM(x, ns)) return } if x.Tag == vdom.TagPortal { // The placeholder stays put; the children diff against the body-level // container, not against `parent`. vdom.SetRefNode(x.Ref, rt(x).portal) patchChildren(rt(x).portal, o.Children, x.Children) return } // x is a fresh VNode each render, so re-point its ref at the adopted node. vdom.SetRefNode(x.Ref, 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 { // 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) } } 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 && !propEquals(dom, k, v) { setProp(dom, k, v) } } } // setProp writes a live DOM property. Props travel as strings, but the property they // land on may be a boolean — and JS reads the string "false" as TRUE, so writing // el.checked = "false" ticks the box. Boolean props are converted before the write. func setProp(el js.Value, k, v string) { if vdom.IsBoolProp(k) { el.Set(k, v == "true") return } el.Set(k, v) } // propEquals compares against what the DOM currently holds, in the property's own // type. It is what lets a re-render skip touching an the user is typing in. func propEquals(el js.Value, k, v string) bool { cur := el.Get(k) if vdom.IsBoolProp(k) { return cur.Truthy() == (v == "true") } return cur.String() == 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 { r := rt(n) for _, fn := range r.jsFuncs { fn.Release() } // A portal's children live at body level, so removing the placeholder from // the tree does not remove them — the container has to go explicitly, or the // panel outlives the component that opened it. if r.portal.Truthy() { if p := r.portal.Get("parentNode"); p.Truthy() { p.Call("removeChild", r.portal) } } detachRef(n, r) } for _, c := range n.Children { release(c) } } // detachRef nils out the node's Ref — but only if the ref still points at the node // being released. On a tag change the reconciler creates the replacement *before* // releasing the old node (replaceChild needs both), and a component holds one Ref // across renders, so an unconditional detach here would nil the ref that createDOM // had just pointed at the new element. func detachRef(n *vdom.VNode, r *nodeRT) { if n.Ref == nil { return } cur, ok := n.Ref.Node().(js.Value) if !ok { return } if cur.Equal(r.dom) || (r.portal.Truthy() && cur.Equal(r.portal)) { vdom.SetRefNode(n.Ref, nil) } } // ---- 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 vdom.SetRefNode(n.Ref, dom) if n.Tag == vdom.TagPortal { // SSR emitted the placeholder and nothing else (the server does not own // document.body), so adopt the placeholder but build the children fresh. r := rt(n) r.portal = newPortalContainer() vdom.SetRefNode(n.Ref, r.portal) for _, c := range n.Children { r.portal.Call("appendChild", createDOM(c, "")) } return } 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) } // Reconcile attributes against the client's tree. Hydration ADOPTS the server's // DOM, so without this any disagreement between the server's HTML and the client's // render is baked in permanently: the DOM keeps the server's value, and no later // re-render fixes it either, because updateAttrs compares one client VNode against // the next — both of which agree with each other and disagree with the DOM. // // The client is the source of truth once it is running. It is also the only one of // the two that can be out of date in the other direction (a stale server binary, // an SSR cache), and a silently-wrong class is far worse than a redundant // setAttribute on first paint. for k, v := range n.Attrs { if dom.Call("getAttribute", k).String() != v { dom.Call("setAttribute", k, v) } } for k, v := range n.Props { setProp(dom, 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) } }