475 lines
13 KiB
Go
475 lines
13 KiB
Go
//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/wasmruntime/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 <svg> 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
|
|
// <svg> switches into the SVG namespace; <foreignObject> 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 <svg>.
|
|
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 <div> 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 <input> 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)
|
|
}
|
|
}
|