// Package vdom is a platform-neutral virtual DOM shared by the server (renders // to an HTML string) and the client (reconciles into the real DOM). It compiles // on BOTH native and js/wasm, so the same component code runs in both places — // which is what makes server-side rendering + client hydration possible. package vdom import ( "html" "sort" "strings" ) // Event is a DOM event passed to handlers. The client provides a concrete // implementation; on the server events are never invoked, and every accessor // returns its zero value. type Event interface { PreventDefault() StopPropagation() Value() string // target.value (for inputs) Checked() bool // target.checked (for checkboxes / radios) Key() string // KeyboardEvent.key — compare against the KEY_* constants ClientX() int // pointer position, viewport coordinates ClientY() int // Target() any // the platform DOM handle of event.target; nil on the server SetData(format, data string) // DataTransfer — no-ops off a drag event GetData(format string) string } // VNode is a virtual DOM node. Tag == "" is a text node (content in Text). HTML, // if set on an element, is raw innerHTML (children ignored). Tag == TagPortal // renders its children into document.body instead of in place. type VNode struct { Tag string Text string HTML string Attrs map[string]string Props map[string]string Events map[string]func(Event) Children []*VNode // Ref, if set, receives the DOM node this VNode renders to (see WithRef). Ref *Ref // Runtime holds the wasm reconciler's per-node bookkeeping (DOM handle, // listener wrappers). It's `any` so this package stays platform-neutral; it // is nil on the server. Runtime any } // Mod configures a VNode while it is built. type Mod interface{ apply(*VNode) } // TagPortal marks a node whose children are mounted into document.body rather // than into its own parent. It is how a floating panel escapes an ancestor's // `overflow: hidden` (menus scroll, modals clip) or `transform` (which would // re-root `position: fixed` onto the modal instead of the viewport). In the tree // it occupies a hidden, zero-size placeholder; the children live at body level. const TagPortal = "#portal" // El builds an element VNode. func El(tag string, mods ...Mod) *VNode { n := &VNode{Tag: tag, Attrs: map[string]string{}, Props: map[string]string{}, Events: map[string]func(Event){}} for _, m := range mods { m.apply(n) } return n } // Portal renders its children into document.body. On the server it renders the // placeholder only — floating content is closed during SSR, so there is nothing // to emit, and the placeholder keeps the client's hydration walk aligned. func Portal(children ...*VNode) *VNode { n := El(TagPortal) for _, c := range children { c.apply(n) } return n } // Text builds a text VNode. func Text(s string) *VNode { return &VNode{Text: s} } func (n *VNode) apply(parent *VNode) { if n != nil { // a nil child renders nothing (e.g. a closed Modal/Menu returns nil) parent.Children = append(parent.Children, n) } } type attrMod struct{ k, v string } func (a attrMod) apply(n *VNode) { n.Attrs[a.k] = a.v } // Attr sets an HTML attribute. func Attr(k, v string) Mod { return attrMod{k, v} } type propMod struct{ k, v string } func (p propMod) apply(n *VNode) { n.Props[p.k] = p.v } // Prop sets a live DOM property (e.g. an input's value). func Prop(k, v string) Mod { return propMod{k, v} } type htmlMod struct{ html string } func (h htmlMod) apply(n *VNode) { n.HTML = h.html } // Raw sets inner HTML verbatim (children ignored) — e.g. a server-computed SVG. func Raw(html string) Mod { return htmlMod{html} } type eventMod struct { name string h func(Event) } func (e eventMod) apply(n *VNode) { n.Events[e.name] = e.h } // On registers an event handler that ignores the event object. func On(event string, h func()) Mod { return eventMod{event, func(Event) { h() }} } // OnEvent registers an event handler that receives the Event. func OnEvent(event string, h func(Event)) Mod { return eventMod{event, h} } // --- server-side HTML rendering (used for SSR; runs on any platform) --- var voidTags = map[string]bool{"br": true, "hr": true, "img": true, "input": true, "meta": true, "link": true} // RenderHTML serializes a VNode tree to HTML with NO extra whitespace, so the // browser's parsed childNodes line up 1:1 with the VNode children on hydration. func RenderHTML(n *VNode) string { var b strings.Builder writeNode(&b, n) return b.String() } func writeNode(b *strings.Builder, n *VNode) { if n.Tag == "" { b.WriteString(html.EscapeString(n.Text)) return } if n.Tag == TagPortal { // Placeholder only — the children belong to document.body, which SSR does // not own. Emitting the same element the client creates keeps hydration's // childNodes lined up. b.WriteString(`
`) return } b.WriteByte('<') b.WriteString(n.Tag) writeAttrs(b, n.Attrs) writeAttrs(b, n.Props) // props like input value show up as attributes in SSR b.WriteByte('>') if voidTags[n.Tag] { return } if n.HTML != "" { b.WriteString(n.HTML) // raw } else { for _, c := range n.Children { writeNode(b, c) } } b.WriteString("") b.WriteString(n.Tag) b.WriteByte('>') } func writeAttrs(b *strings.Builder, m map[string]string) { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { b.WriteByte(' ') b.WriteString(k) b.WriteString(`="`) b.WriteString(html.EscapeString(m[k])) b.WriteByte('"') } }