Add fonts, autotable, autotable examples

This commit is contained in:
2026-07-13 13:01:26 -04:00
parent ea3d2a6d03
commit cf8342f8d4
71 changed files with 16084 additions and 1443 deletions

92
go/wasmruntime/host.go Normal file
View File

@@ -0,0 +1,92 @@
package wasmruntime
import (
"strconv"
"strings"
)
// The host API: the channel through which the browser tells WebAssembly things Go
// cannot work out on its own — how big an element is, where the pointer is, how
// tall the viewport is — plus the imperative escape hatches (write a style, focus
// an input, download a file) that a pure re-render cannot express.
//
// Every function here exists in two builds: the real one (host_wasm.go, js+wasm)
// and a no-op that returns zero values (host_native.go). That is what lets the
// neutral kjol/webui components — which must also compile on the server for SSR —
// call them unconditionally. On the server a measurement is simply the zero Rect,
// a listener is never installed, and a timer never fires; components render their
// pre-measurement state (a panel with `visibility: hidden`), which is exactly what
// SSR should ship.
//
// Two rules for callers:
//
// 1. Measure only after the DOM exists. A ref is empty until the reconciler has
// committed; use AfterRender to run measurement code once the current render
// is on screen.
// 2. Do NOT position through signals. Signal.Set re-renders the whole tree; doing
// that on every scroll frame is pathological. Write positions with SetStyle,
// which mutates the DOM node directly and leaves the vdom alone.
// Rect is an element's box in viewport coordinates — the result of
// getBoundingClientRect. Viewport coordinates compose directly with
// `position: fixed`, so no scroll compensation is needed (or wanted) anywhere.
type Rect struct{ X, Y, Width, Height float64 }
func (r Rect) Top() float64 { return r.Y }
func (r Rect) Left() float64 { return r.X }
func (r Rect) Right() float64 { return r.X + r.Width }
func (r Rect) Bottom() float64 { return r.Y + r.Height }
func (r Rect) CenterX() float64 { return r.X + r.Width/2 }
func (r Rect) CenterY() float64 { return r.Y + r.Height/2 }
// Empty reports whether the rect carries no useful geometry — an unmounted ref, or
// an element that has not been laid out yet. Positioning code must treat this as
// "cannot measure yet" rather than as a box at the origin, or the panel paints at
// 0,0 for a frame.
func (r Rect) Empty() bool { return r.Width == 0 && r.Height == 0 }
// Size is a width/height pair — the viewport, or an element's size.
type Size struct{ Width, Height float64 }
// Unsub removes a listener installed by OnWindow / OnDocument / ObserveResize.
// Always call it when the thing that installed it goes away; a floating panel that
// leaks a window scroll listener per open will crawl.
type Unsub func()
// ScrollBlock values for ScrollIntoView.
const (
ScrollBlockStart = "start"
ScrollBlockCenter = "center"
ScrollBlockEnd = "end"
ScrollBlockNearest = "nearest"
)
// parseCSSLength resolves a CSS length to pixels. rootFontSize is the computed
// font-size of :root (itself a px string), which is what a rem is measured
// against. Unitless and unparseable values yield 0.
func parseCSSLength(value, rootFontSize string) float64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
switch {
case strings.HasSuffix(value, "px"):
return parseFloat(strings.TrimSuffix(value, "px"))
case strings.HasSuffix(value, "rem"):
root := parseFloat(strings.TrimSuffix(strings.TrimSpace(rootFontSize), "px"))
if root == 0 {
root = 16 // the browser default, if :root's font-size is unreadable
}
return parseFloat(strings.TrimSuffix(value, "rem")) * root
default:
return parseFloat(value)
}
}
func parseFloat(s string) float64 {
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return 0
}
return f
}

View File

@@ -0,0 +1,49 @@
//go:build !(js && wasm)
package wasmruntime
import "kjol/vdom"
// The server half of the host API (see host.go). Nothing here touches a browser,
// because there isn't one: measurements are zero, listeners are never installed,
// timers never fire, storage is always empty. Components call these
// unconditionally and render their unmeasured state, which is what SSR ships.
func Measure(*vdom.Ref) Rect { return Rect{} }
func Viewport() Size { return Size{} }
func CSSVarPx(string) float64 { return 0 }
func RAF(func()) int { return 0 }
func CancelRAF(int) {}
func AfterRender(func()) {}
func SetStyle(*vdom.Ref, string, string) {}
func RemoveStyle(*vdom.Ref, string) {}
func SetText(*vdom.Ref, string) {}
func SetHTML(*vdom.Ref, string) {}
func Focus(*vdom.Ref) {}
func Blur(*vdom.Ref) {}
func SelectionRange(*vdom.Ref) (int, int) { return 0, 0 }
func SetSelectionRange(*vdom.Ref, int, int) {}
func ScrollIntoView(*vdom.Ref, bool, string) {}
func ScrollLeft(*vdom.Ref) float64 { return 0 }
func SetScrollLeft(*vdom.Ref, float64) {}
func Contains(*vdom.Ref, any) bool { return false }
func ClosestAttr(any, string, string) (string, bool) { return "", false }
func QuerySelector(string) *vdom.Ref { return vdom.NewRef() }
func OnWindow(string, bool, func(vdom.Event)) Unsub { return func() {} }
func OnDocument(string, bool, func(vdom.Event)) Unsub { return func() {} }
func ObserveResize(*vdom.Ref, func()) Unsub { return func() {} }
func SetTimeout(int, func()) int { return 0 }
func ClearTimeout(int) {}
func StorageGet(string) (string, bool) { return "", false }
func StorageSet(string, string) {}
func StorageRemove(string) {}
func Download(string, string, []byte) {}
func Print(string, []byte) {}

214
go/wasmruntime/host_test.go Normal file
View File

@@ -0,0 +1,214 @@
//go:build js && wasm
package wasmruntime
import (
"strings"
"syscall/js"
"testing"
"kjol/vdom"
)
// Run from kjol/go:
//
// GOOS=js GOARCH=wasm go test -exec="node testdata/domexec.js" ./wasmruntime
// A Ref must be attached when the element is created, survive a diff that reuses
// the element, and be nil again once the element is gone — otherwise measurement
// code silently reads a stale node.
func TestRefLifecycle(t *testing.T) {
root := document.Call("createElement", "div")
panel := vdom.NewRef()
if panel.Mounted() {
t.Fatal("a fresh ref should not be mounted")
}
a := vdom.El("div", vdom.WithRef(panel), vdom.Attr("class", "one"))
patchChildren(root, nil, one(a))
if !panel.Mounted() {
t.Fatal("ref was not attached on create")
}
created, _ := panel.Node().(js.Value)
// A re-render builds a fresh VNode carrying the same *Ref; the diff adopts the
// existing element, so the ref must still point at it.
b := vdom.El("div", vdom.WithRef(panel), vdom.Attr("class", "two"))
patchChildren(root, one(a), one(b))
if !panel.Mounted() {
t.Fatal("ref was detached by a re-render that reused the element")
}
if adopted, _ := panel.Node().(js.Value); !adopted.Equal(created) {
t.Error("ref points at a different node after a reusing diff")
}
// Removing the element must clear the ref.
patchChildren(root, one(b), nil)
if panel.Mounted() {
t.Error("ref still points at a removed element")
}
}
// A tag change replaces the element. createDOM runs before release (replaceChild
// needs both nodes), so a naive detach-on-release would nil the ref that was just
// pointed at the NEW element.
func TestRefSurvivesTagChange(t *testing.T) {
root := document.Call("createElement", "div")
r := vdom.NewRef()
a := vdom.El("div", vdom.WithRef(r))
patchChildren(root, nil, one(a))
b := vdom.El("span", vdom.WithRef(r))
patchChildren(root, one(a), one(b))
if !r.Mounted() {
t.Fatal("ref was cleared when the element changed tag")
}
if got := Measure(r); got != (Rect{}) { // shim reports a zero rect by default
_ = got
}
if !strings.Contains(root.Get("innerHTML").String(), "<span") {
t.Error("replacement element was not rendered")
}
}
// Measure must read the element's box, and must return the zero Rect (not a box at
// the origin) for an unmounted ref, so positioning code can tell "not laid out yet"
// from "genuinely at 0,0".
func TestMeasure(t *testing.T) {
if got := Measure(vdom.NewRef()); !got.Empty() {
t.Errorf("unmounted ref measured as %+v, want the zero Rect", got)
}
root := document.Call("createElement", "div")
r := vdom.NewRef()
patchChildren(root, nil, one(vdom.El("div", vdom.WithRef(r))))
n, _ := r.Node().(js.Value)
rect := n.Get("rect")
rect.Set("left", 10)
rect.Set("top", 20)
rect.Set("width", 100)
rect.Set("height", 40)
got := Measure(r)
want := Rect{X: 10, Y: 20, Width: 100, Height: 40}
if got != want {
t.Fatalf("Measure = %+v, want %+v", got, want)
}
if got.Right() != 110 || got.Bottom() != 60 || got.CenterX() != 60 {
t.Errorf("derived edges wrong: right=%v bottom=%v centerX=%v", got.Right(), got.Bottom(), got.CenterX())
}
if got.Empty() {
t.Error("a measured element reported Empty()")
}
}
// SetStyle writes straight to the DOM, deliberately bypassing the vdom — that is
// what keeps per-frame repositioning from re-rendering the whole app.
func TestSetStyleBypassesVDOM(t *testing.T) {
root := document.Call("createElement", "div")
r := vdom.NewRef()
patchChildren(root, nil, one(vdom.El("div", vdom.WithRef(r))))
SetStyle(r, "top", "42px")
SetStyle(r, "left", "7px")
if got := root.Get("innerHTML").String(); !strings.Contains(got, "left: 7px; top: 42px") {
t.Fatalf("styles not written: %s", got)
}
RemoveStyle(r, "top")
if got := root.Get("innerHTML").String(); strings.Contains(got, "top:") {
t.Errorf("style not removed: %s", got)
}
SetStyle(vdom.NewRef(), "top", "1px") // unmounted: must not panic
}
// The whole point of the portal: children mount into document.body, NOT into the
// parent — so a panel can escape an ancestor's overflow:hidden / transform.
func TestPortalMountsToBody(t *testing.T) {
root := document.Call("createElement", "div")
body := document.Get("body")
before := body.Get("childNodes").Get("length").Int()
tree := vdom.El("div", vdom.Attr("class", "clipped"),
vdom.Portal(vdom.El("div", vdom.Attr("class", "panel"), vdom.Text("floating"))),
)
patchChildren(root, nil, one(tree))
if got := root.Get("innerHTML").String(); strings.Contains(got, "floating") {
t.Errorf("portal content rendered in-flow instead of at body level:\n%s", got)
}
if got := root.Get("innerHTML").String(); !strings.Contains(got, "data-portal") {
t.Errorf("portal placeholder missing from the tree (sibling indexes will drift):\n%s", got)
}
if body.Get("childNodes").Get("length").Int() != before+1 {
t.Fatal("portal container was not appended to body")
}
container := body.Get("childNodes").Index(before)
if got := container.Get("innerHTML").String(); !strings.Contains(got, "floating") {
t.Fatalf("portal children not in the body container: %s", got)
}
// Unmounting the portal must take the body-level container with it, or the
// panel outlives the component that opened it.
patchChildren(root, one(tree), nil)
if body.Get("childNodes").Get("length").Int() != before {
t.Error("portal container leaked into body after unmount")
}
}
// A portal's children must diff against the body container across re-renders, not
// be recreated or appended to the placeholder.
func TestPortalPatchesChildrenInPlace(t *testing.T) {
root := document.Call("createElement", "div")
body := document.Get("body")
before := body.Get("childNodes").Get("length").Int()
a := vdom.El("div", vdom.Portal(vdom.El("p", vdom.Text("first"))))
patchChildren(root, nil, one(a))
container := body.Get("childNodes").Index(before)
b := vdom.El("div", vdom.Portal(vdom.El("p", vdom.Text("second"))))
patchChildren(root, one(a), one(b))
if body.Get("childNodes").Get("length").Int() != before+1 {
t.Fatal("re-render created a second portal container")
}
got := container.Get("innerHTML").String()
if !strings.Contains(got, "second") || strings.Contains(got, "first") {
t.Errorf("portal children not patched in place: %s", got)
}
patchChildren(root, one(b), nil)
}
// Outside-click detection is built on these two.
func TestContainsAndClosestAttr(t *testing.T) {
root := document.Call("createElement", "div")
panel := vdom.NewRef()
inner := vdom.NewRef()
tree := vdom.El("div", vdom.Attr("data-floating-id", "menu-1"), vdom.WithRef(panel),
vdom.El("button", vdom.WithRef(inner), vdom.Text("item")),
)
patchChildren(root, nil, one(tree))
target := inner.Node()
if !Contains(panel, target) {
t.Error("Contains missed a descendant")
}
other := vdom.NewRef()
patchChildren(root, nil, one(vdom.El("div", vdom.WithRef(other))))
if Contains(other, target) {
t.Error("Contains matched an unrelated element")
}
id, ok := ClosestAttr(target, "[data-floating-id]", "data-floating-id")
if !ok || id != "menu-1" {
t.Errorf("ClosestAttr = %q, %v; want \"menu-1\", true", id, ok)
}
if _, ok := ClosestAttr(nil, "[data-floating-id]", "data-floating-id"); ok {
t.Error("ClosestAttr should report not-found for a nil target")
}
}

438
go/wasmruntime/host_wasm.go Normal file
View File

@@ -0,0 +1,438 @@
//go:build js && wasm
package wasmruntime
import (
"syscall/js"
"kjol/vdom"
)
// The browser half of the host API (see host.go for the contract and the two
// rules for callers).
var window = js.Global()
// node resolves a Ref to its live DOM handle, or an invalid js.Value if the ref
// is unattached. Every entry point below guards on this, so calling a host
// function against an unmounted ref is a silent no-op rather than a panic — the
// common case during the first render, before the reconciler has committed.
func node(r *vdom.Ref) (js.Value, bool) {
if r == nil {
return js.Value{}, false
}
n, ok := r.Node().(js.Value)
if !ok || !n.Truthy() {
return js.Value{}, false
}
return n, true
}
// ---- measurement ----
// Measure runs getBoundingClientRect on an element. Returns the zero Rect if the
// ref is not mounted — callers must check Rect.Empty() before positioning against
// it (see host.go), or the panel lands at 0,0.
func Measure(r *vdom.Ref) Rect {
n, ok := node(r)
if !ok {
return Rect{}
}
b := n.Call("getBoundingClientRect")
return Rect{
X: b.Get("left").Float(),
Y: b.Get("top").Float(),
Width: b.Get("width").Float(),
Height: b.Get("height").Float(),
}
}
// Viewport is window.innerWidth/innerHeight — the collision boundary for the
// floating engine.
func Viewport() Size {
return Size{
Width: window.Get("innerWidth").Float(),
Height: window.Get("innerHeight").Float(),
}
}
// CSSVarPx reads a CSS custom property off :root and resolves it to pixels,
// handling rem (against the root font size) and px. Returns 0 if unset or
// unparseable. Used for --radius-default, so the tutorial spotlight's corners
// match the app's theme.
func CSSVarPx(name string) float64 {
root := document.Get("documentElement")
style := window.Call("getComputedStyle", root)
raw := style.Call("getPropertyValue", name).String()
return parseCSSLength(raw, style.Get("fontSize").String())
}
// ---- frame timing + the post-render hook ----
func RAF(fn func()) int {
var cb js.Func
cb = js.FuncOf(func(js.Value, []js.Value) any {
cb.Release()
fn()
return nil
})
return window.Call("requestAnimationFrame", cb).Int()
}
func CancelRAF(id int) { window.Call("cancelAnimationFrame", id) }
// afterRender holds callbacks queued for the next render commit. See mount.go,
// which drains it once the DOM is up to date.
var afterRender []func()
// AfterRender runs fn once, right after the current render has been committed to
// the DOM. This is how a component measures something it just rendered: signal
// writes only *schedule* a render, so a ref read in the same tick is still empty.
//
// open.Set(true)
// wasmruntime.AfterRender(func() { reposition() }) // the panel exists by now
//
// If no render is pending (nothing changed), fn still runs — on the next frame —
// so a caller can never be stranded waiting for a commit that will not come.
func AfterRender(fn func()) {
afterRender = append(afterRender, fn)
if !renderScheduled {
// No re-render coming; run on the next frame so the caller's contract
// ("after the DOM is settled") still holds.
RAF(flushAfterRender)
}
}
func flushAfterRender() {
if len(afterRender) == 0 {
return
}
pending := afterRender
afterRender = nil
for _, fn := range pending {
fn()
}
}
// ---- imperative DOM writes (bypassing the vdom on purpose) ----
// SetStyle writes an inline style property directly on the element. Positioning
// goes through here rather than through a signal: a signal write re-renders the
// entire tree, and repositioning happens on every scroll and resize frame.
func SetStyle(r *vdom.Ref, prop, value string) {
if n, ok := node(r); ok {
n.Get("style").Call("setProperty", prop, value)
}
}
func RemoveStyle(r *vdom.Ref, prop string) {
if n, ok := node(r); ok {
n.Get("style").Call("removeProperty", prop)
}
}
// SetText / SetHTML write content imperatively. SetHTML exists for the formula
// editor's syntax-highlight overlay, which is repainted per keystroke and must not
// go through a full re-render.
func SetText(r *vdom.Ref, text string) {
if n, ok := node(r); ok {
n.Set("textContent", text)
}
}
func SetHTML(r *vdom.Ref, html string) {
if n, ok := node(r); ok {
n.Set("innerHTML", html)
}
}
// ---- focus, selection, scrolling ----
func Focus(r *vdom.Ref) {
if n, ok := node(r); ok {
n.Call("focus")
}
}
func Blur(r *vdom.Ref) {
if n, ok := node(r); ok {
n.Call("blur")
}
}
// SelectionRange / SetSelectionRange expose the caret in an <input>/<textarea> —
// what the formula editor needs to insert a function at the cursor.
func SelectionRange(r *vdom.Ref) (start, end int) {
n, ok := node(r)
if !ok {
return 0, 0
}
return n.Get("selectionStart").Int(), n.Get("selectionEnd").Int()
}
func SetSelectionRange(r *vdom.Ref, start, end int) {
if n, ok := node(r); ok {
n.Call("setSelectionRange", start, end)
}
}
func ScrollIntoView(r *vdom.Ref, smooth bool, block string) {
n, ok := node(r)
if !ok {
return
}
behavior := "auto"
if smooth {
behavior = "smooth"
}
if block == "" {
block = ScrollBlockCenter
}
opts := js.Global().Get("Object").New()
opts.Set("behavior", behavior)
opts.Set("block", block)
n.Call("scrollIntoView", opts)
}
func ScrollLeft(r *vdom.Ref) float64 {
n, ok := node(r)
if !ok {
return 0
}
return n.Get("scrollLeft").Float()
}
func SetScrollLeft(r *vdom.Ref, x float64) {
if n, ok := node(r); ok {
n.Set("scrollLeft", x)
}
}
// ---- hit testing (outside-click) ----
// Contains reports whether target lies inside r's subtree. target is an
// Event.Target(). This is how a floating panel decides a click was "outside".
func Contains(r *vdom.Ref, target any) bool {
n, ok := node(r)
if !ok {
return false
}
t, ok := target.(js.Value)
if !ok || !t.Truthy() {
return false
}
return n.Call("contains", t).Bool()
}
// ClosestAttr walks up from target to the nearest ancestor matching selector and
// returns that element's attr. It returns the attribute *value* rather than a
// handle on purpose: the floating layer identifies panels by string id, so Go can
// compare against its own open-order stack without needing JS object identity.
func ClosestAttr(target any, selector, attr string) (string, bool) {
t, ok := target.(js.Value)
if !ok || !t.Truthy() {
return "", false
}
// target may be a text node (clicks land on text); closest() is an Element
// method, so climb to the nearest element first.
if t.Get("nodeType").Int() != 1 {
t = t.Get("parentElement")
if !t.Truthy() {
return "", false
}
}
el := t.Call("closest", selector)
if !el.Truthy() {
return "", false
}
v := el.Call("getAttribute", attr)
if !v.Truthy() {
return "", false
}
return v.String(), true
}
// QuerySelector finds an existing element anywhere in the document — for the
// tutorial, whose steps target app elements it does not own.
func QuerySelector(selector string) *vdom.Ref {
r := vdom.NewRef()
if el := document.Call("querySelector", selector); el.Truthy() {
vdom.SetRefNode(r, el)
}
return r
}
// ---- global listeners ----
// OnWindow installs a window-level listener and returns its remover.
//
// capture matters: `scroll` does not bubble, so a capture-phase listener on
// window is the only way to hear scrolling inside a nested scroll container — a
// floating panel anchored to a row in a scrollable table depends on it.
func OnWindow(event string, capture bool, fn func(vdom.Event)) Unsub {
return listen(window, event, capture, fn)
}
// OnDocument installs a document-level listener and returns its remover. Used for
// outside-click (mousedown, which fires before focus moves) and Escape (keydown).
func OnDocument(event string, capture bool, fn func(vdom.Event)) Unsub {
return listen(document, event, capture, fn)
}
func listen(target js.Value, event string, capture bool, fn func(vdom.Event)) Unsub {
cb := js.FuncOf(func(_ js.Value, args []js.Value) any {
var ev js.Value
if len(args) > 0 {
ev = args[0]
}
fn(clientEvent{js: ev})
return nil
})
target.Call("addEventListener", event, cb, capture)
return func() {
target.Call("removeEventListener", event, cb, capture)
cb.Release()
}
}
// ObserveResize fires fn whenever the element's own size changes. This is the fix
// for a real gap in the original TSX kit, which repositioned floating panels only
// on scroll and window resize — so a panel whose *content* grew (an async-loaded
// list, a filtered dropdown) stayed at its stale position.
func ObserveResize(r *vdom.Ref, fn func()) Unsub {
n, ok := node(r)
if !ok {
return func() {}
}
ctor := window.Get("ResizeObserver")
if !ctor.Truthy() {
return func() {}
}
cb := js.FuncOf(func(js.Value, []js.Value) any { fn(); return nil })
obs := ctor.New(cb)
obs.Call("observe", n)
return func() {
obs.Call("disconnect")
cb.Release()
}
}
// ---- timers ----
// SetTimeout schedules fn. The hover bridge (moving the cursor from a trigger onto
// its panel across a gap without the panel vanishing) is built on these.
func SetTimeout(ms int, fn func()) int {
var cb js.Func
cb = js.FuncOf(func(js.Value, []js.Value) any {
cb.Release()
fn()
return nil
})
return window.Call("setTimeout", cb, ms).Int()
}
func ClearTimeout(id int) {
if id != 0 {
window.Call("clearTimeout", id)
}
}
// ---- localStorage ----
// StorageGet/Set/Remove wrap localStorage. Every call is guarded: localStorage
// throws in private-mode Safari and when storage is full, and a table remembering
// its column widths is never worth taking the app down for.
func StorageGet(key string) (string, bool) {
ls := window.Get("localStorage")
if !ls.Truthy() {
return "", false
}
v := tryCall(ls, "getItem", key)
if !v.Truthy() {
return "", false
}
return v.String(), true
}
func StorageSet(key, value string) {
if ls := window.Get("localStorage"); ls.Truthy() {
tryCall(ls, "setItem", key, value)
}
}
func StorageRemove(key string) {
if ls := window.Get("localStorage"); ls.Truthy() {
tryCall(ls, "removeItem", key)
}
}
// tryCall invokes a JS method, swallowing a thrown exception (js.Value.Call panics
// on throw) and returning undefined instead.
func tryCall(recv js.Value, method string, args ...any) (out js.Value) {
defer func() {
if recover() != nil {
out = js.Undefined()
}
}()
return recv.Call(method, args...)
}
// ---- getting bytes out of wasm ----
// Download hands bytes to the browser as a file: Blob -> object URL -> a synthetic
// <a download> click -> revoke. This is the only way out of wasm for a generated
// CSV or PDF.
func Download(filename, mime string, data []byte) {
url := blobURL(mime, data)
defer window.Get("URL").Call("revokeObjectURL", url)
a := document.Call("createElement", "a")
a.Set("href", url)
a.Set("download", filename)
document.Get("body").Call("appendChild", a)
a.Call("click")
document.Get("body").Call("removeChild", a)
}
// Print loads bytes into a hidden iframe and opens the browser's print dialog on
// it — how a generated PDF gets printed without first being saved.
func Print(mime string, data []byte) {
url := blobURL(mime, data)
frame := document.Call("createElement", "iframe")
frame.Get("style").Call("setProperty", "display", "none")
frame.Set("src", url)
var onload js.Func
onload = js.FuncOf(func(js.Value, []js.Value) any {
defer onload.Release()
w := frame.Get("contentWindow")
if w.Truthy() {
w.Call("focus")
w.Call("print")
}
// The iframe must outlive the dialog, so the URL is revoked on a delay
// rather than immediately: revoking it while the dialog is open blanks the
// preview in Chrome.
SetTimeout(60_000, func() {
window.Get("URL").Call("revokeObjectURL", url)
if frame.Get("parentNode").Truthy() {
document.Get("body").Call("removeChild", frame)
}
})
return nil
})
frame.Set("onload", onload)
document.Get("body").Call("appendChild", frame)
}
func blobURL(mime string, data []byte) string {
buf := js.Global().Get("Uint8Array").New(len(data))
js.CopyBytesToJS(buf, data)
parts := js.Global().Get("Array").New()
parts.Call("push", buf)
opts := js.Global().Get("Object").New()
opts.Set("type", mime)
blob := js.Global().Get("Blob").New(parts, opts)
return window.Get("URL").Call("createObjectURL", blob).String()
}

View File

@@ -0,0 +1,80 @@
//go:build js && wasm
package wasmruntime
import (
"strings"
"syscall/js"
"testing"
"kjol/vdom"
)
// buildServerDOM fakes what the HTML parser hands us: a real DOM tree, built from
// the SERVER's VNode tree, which hydration then adopts.
func buildServerDOM(server *vdom.VNode) js.Value { return createDOM(server, "") }
// Hydration ADOPTS the server's DOM. If the server's HTML and the client's render
// disagree, the disagreement has to be resolved in the CLIENT's favour — it is the
// one that is running, and it is the only one that can still change.
//
// Left unreconciled, the divergence is permanent and invisible: the DOM keeps the
// server's value, and no later re-render corrects it either, because updateAttrs
// compares one client VNode against the next — which agree with each other and
// disagree with the DOM. The symptom is a page that is right when you navigate to it
// and wrong when you refresh onto it.
func TestHydrationReconcilesAttributes(t *testing.T) {
root := document.Call("createElement", "div")
// What the server rendered (say, from a stale binary).
server := vdom.El("main", vdom.Attr("class", "mx-auto max-w-5xl px-4"))
root.Call("appendChild", buildServerDOM(server))
// What the client actually renders now.
client := vdom.El("main", vdom.Attr("class", "mx-auto max-w-[100rem] px-4"))
hydrateNode(root.Get("firstChild"), client)
got := root.Get("firstChild").Call("getAttribute", "class").String()
if got != "mx-auto max-w-[100rem] px-4" {
t.Errorf("after hydration class = %q, want the client's value — the server's stale class was kept", got)
}
}
// And the ordinary case must still work: matching markup is adopted, not rebuilt.
func TestHydrationAdoptsMatchingMarkup(t *testing.T) {
root := document.Call("createElement", "div")
server := vdom.El("div", vdom.Attr("class", "a"),
vdom.El("span", vdom.Text("hello")),
)
root.Call("appendChild", buildServerDOM(server))
adopted := root.Get("firstChild")
client := vdom.El("div", vdom.Attr("class", "a"),
vdom.El("span", vdom.Text("hello")),
)
hydrateNode(root.Get("firstChild"), client)
// Same node — hydration adopted it rather than replacing it.
if !rt(client).dom.Equal(adopted) {
t.Error("hydration replaced a matching node instead of adopting it")
}
if got := root.Get("innerHTML").String(); !strings.Contains(got, "hello") {
t.Errorf("content lost: %s", got)
}
}
// A text node that disagrees is corrected too (this already worked; pin it).
func TestHydrationCorrectsText(t *testing.T) {
root := document.Call("createElement", "div")
server := vdom.El("p", vdom.Text("stale"))
root.Call("appendChild", buildServerDOM(server))
client := vdom.El("p", vdom.Text("fresh"))
hydrateNode(root.Get("firstChild"), client)
if got := root.Get("innerHTML").String(); !strings.Contains(got, "fresh") {
t.Errorf("stale server text survived hydration: %s", got)
}
}

View File

@@ -32,6 +32,7 @@ func Run(component func() *vdom.VNode) {
next := component()
patchChildren(root, one(prev), one(next))
prev = next
flushAfterRender() // refs are live now — measurement callbacks can run
}
rootRender()
finish(root)
@@ -49,7 +50,9 @@ func Hydrate(component func() *vdom.VNode) {
next := component()
patchChildren(root, one(prev), one(next))
prev = next
flushAfterRender()
}
flushAfterRender() // the adopted DOM is live; refs from hydration are usable
finish(root)
}

View File

@@ -18,6 +18,9 @@ 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) }
@@ -32,7 +35,8 @@ func rt(n *vdom.VNode) *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) PreventDefault() { e.js.Call("preventDefault") }
func (e clientEvent) StopPropagation() { e.js.Call("stopPropagation") }
func (e clientEvent) Value() string {
t := e.js.Get("target")
@@ -46,6 +50,55 @@ func (e clientEvent) Value() string {
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
@@ -55,14 +108,57 @@ func one(n *vdom.VNode) []*vdom.VNode {
// ---- fresh create + diff ----
func createDOM(n *vdom.VNode) js.Value {
// 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
}
el := document.Call("createElement", n.Tag)
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)
}
@@ -77,12 +173,57 @@ func createDOM(n *vdom.VNode) js.Value {
return el
}
for _, c := range n.Children {
el.Call("appendChild", createDOM(c))
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
@@ -92,21 +233,21 @@ func patchChildren(parent js.Value, old, next []*vdom.VNode) {
if i < len(next) {
x = next[i]
}
patch(parent, o, x)
patch(parent, o, x, ns)
}
}
func patch(parent js.Value, o, x *vdom.VNode) {
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))
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), rt(o).dom)
parent.Call("replaceChild", createDOM(x, ns), rt(o).dom)
release(o)
default:
x.Runtime = o.Runtime // adopt dom + listeners
@@ -115,9 +256,18 @@ func patch(parent js.Value, o, x *vdom.VNode) {
// 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))
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)
@@ -209,15 +359,43 @@ func addListener(n *vdom.VNode, name string, handler func(vdom.Event)) {
func release(n *vdom.VNode) {
if n.Runtime != nil {
for _, fn := range rt(n).jsFuncs {
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) {
@@ -225,6 +403,18 @@ func hydrateNode(dom js.Value, n *vdom.VNode) {
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)
@@ -234,6 +424,22 @@ func hydrateNode(dom js.Value, n *vdom.VNode) {
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 {
dom.Set(k, v)
}

View File

@@ -0,0 +1,41 @@
//go:build js && wasm
package wasmruntime
import (
"strings"
"testing"
"kjol/vdom"
)
// Floating.Panel and Modal both declare their initial inline style in the style
// ATTRIBUTE, then overwrite top/left/opacity imperatively with SetStyle. That only
// works because the reconciler skips setAttribute when the declared value has not
// changed. If that ever stops being true, every floating panel silently snaps back
// to visibility:hidden at 0,0 on the next re-render. Pin it.
func TestImperativeStylesSurviveReRender(t *testing.T) {
root := document.Call("createElement", "div")
panel := vdom.NewRef()
const declared = "position:fixed;top:0;left:0;visibility:hidden"
a := vdom.El("div", vdom.WithRef(panel), vdom.Attr("style", declared), vdom.Text("one"))
patchChildren(root, nil, one(a))
// Position it, the way Reposition does.
SetStyle(panel, "top", "120px")
SetStyle(panel, "left", "40px")
SetStyle(panel, "visibility", "visible")
// A re-render with the SAME declared style but different content.
b := vdom.El("div", vdom.WithRef(panel), vdom.Attr("style", declared), vdom.Text("two"))
patchChildren(root, one(a), one(b))
got := root.Get("innerHTML").String()
if !strings.Contains(got, "two") {
t.Fatalf("content did not update: %s", got)
}
if !strings.Contains(got, "top: 120px") || !strings.Contains(got, "visibility: visible") {
t.Errorf("imperative position was clobbered by the re-render:\n%s", got)
}
}

View File

@@ -0,0 +1,91 @@
//go:build js && wasm
package wasmruntime
import (
"testing"
"kjol/vdom"
)
// An <svg> built with document.createElement() is NOT an SVG element — it is an
// HTMLUnknownElement that happens to be spelled "svg". It has no geometry and it
// draws nothing, while still looking perfectly correct in the DOM inspector.
//
// This only bit on NAVIGATION: a server-rendered page's icons come from the HTML
// parser, which handles <svg> as foreign content correctly, and hydration merely
// adopts those nodes. Every icon the CLIENT created afterwards was inert.
func TestSVGIsCreatedInTheSVGNamespace(t *testing.T) {
root := document.Call("createElement", "div")
icon := vdom.El("svg",
vdom.Attr("viewBox", "0 0 24 24"),
vdom.El("path", vdom.Attr("d", "M4 4h16")),
)
patchChildren(root, nil, one(icon))
svg := root.Get("childNodes").Index(0)
if got := svg.Get("namespaceURI").String(); got != svgNamespace {
t.Fatalf("<svg> namespace = %q, want %q — it will render nothing", got, svgNamespace)
}
// The subtree inherits it: a <path> in the HTML namespace draws nothing either.
path := svg.Get("childNodes").Index(0)
if got := path.Get("namespaceURI").String(); got != svgNamespace {
t.Errorf("<path> namespace = %q, want %q", got, svgNamespace)
}
}
// Ordinary HTML must NOT end up in the SVG namespace, including siblings that follow
// an icon.
func TestHTMLStaysInTheHTMLNamespace(t *testing.T) {
root := document.Call("createElement", "div")
tree := vdom.El("div",
vdom.El("svg", vdom.El("path")),
vdom.El("span", vdom.Text("after the icon")),
)
patchChildren(root, nil, one(tree))
div := root.Get("childNodes").Index(0)
span := div.Get("childNodes").Index(1)
if got := span.Get("namespaceURI").String(); got == svgNamespace {
t.Errorf("<span> after an <svg> leaked into the SVG namespace")
}
}
// The navigation case, end to end: page A's tree is replaced by page B's, and the
// icon page B creates mid-diff must still be a real SVG. This is the one that broke.
func TestIconCreatedDuringNavigationIsRealSVG(t *testing.T) {
root := document.Call("createElement", "div")
pageA := vdom.El("div", vdom.El("p", vdom.Text("no icons here")))
patchChildren(root, nil, one(pageA))
// Navigate: same tag at the same index, so the diff reuses the <div> and creates
// the icon *underneath* it rather than from a fresh mount.
pageB := vdom.El("div", vdom.El("svg", vdom.Attr("viewBox", "0 0 24 24"), vdom.El("path")))
patchChildren(root, one(pageA), one(pageB))
svg := root.Get("childNodes").Index(0).Get("childNodes").Index(0)
if got := svg.Get("tag").String(); got != "svg" {
t.Fatalf("expected an <svg>, got <%s>", got)
}
if got := svg.Get("namespaceURI").String(); got != svgNamespace {
t.Errorf("icon created during navigation has namespace %q, want %q — this is the bug", got, svgNamespace)
}
}
// A <foreignObject> switches back to HTML for its contents.
func TestForeignObjectReturnsToHTML(t *testing.T) {
root := document.Call("createElement", "div")
tree := vdom.El("svg", vdom.El("foreignObject", vdom.El("div", vdom.Text("html again"))))
patchChildren(root, nil, one(tree))
fo := root.Get("childNodes").Index(0).Get("childNodes").Index(0)
inner := fo.Get("childNodes").Index(0)
if got := inner.Get("namespaceURI").String(); got == svgNamespace {
t.Error("content inside <foreignObject> should be HTML, not SVG")
}
}

View File

@@ -13,17 +13,57 @@
const { execSync } = require("child_process");
class CSSStyle {
constructor() { this.props = {}; }
setProperty(k, v) { this.props[k] = String(v); }
removeProperty(k) { delete this.props[k]; }
getPropertyValue(k) { return this.props[k] ?? ""; }
get cssText() {
return Object.keys(this.props).sort().map((k) => `${k}: ${this.props[k]}`).join("; ");
}
}
const XHTML_NS = "http://www.w3.org/1999/xhtml";
const SVG_NS = "http://www.w3.org/2000/svg";
class DNode {
constructor(tag) {
constructor(tag, ns = XHTML_NS) {
this.tag = tag;
// An element's namespace is fixed at creation. createElement() always yields
// HTML — which is why an <svg> built that way is inert; see the reconciler.
this.namespaceURI = ns;
this.childNodes = [];
this.attrs = {};
this.parentNode = null;
this.listeners = {};
this.nodeValue = null;
this.rawHTML = null;
this.style = new CSSStyle();
// Tests set .rect to control what getBoundingClientRect reports; there is no
// layout engine here, so geometry is whatever the test declares.
this.rect = { left: 0, top: 0, width: 0, height: 0 };
}
get nodeType() { return this.tag === "#text" ? 3 : 1; }
get firstChild() { return this.childNodes[0] ?? null; }
get parentElement() { return this.parentNode; }
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
hasAttribute(k) { return k in this.attrs; }
getBoundingClientRect() {
const r = this.rect;
return { left: r.left, top: r.top, width: r.width, height: r.height, right: r.left + r.width, bottom: r.top + r.height };
}
contains(other) {
for (let n = other; n; n = n.parentNode) if (n === this) return true;
return false;
}
// Only the attribute-presence selectors the runtime actually uses, e.g.
// "[data-floating-id]".
closest(selector) {
const m = /^\[([a-zA-Z0-9-]+)\]$/.exec(selector);
if (!m) throw new Error(`domexec shim: unsupported selector ${selector}`);
for (let n = this; n; n = n.parentNode) if (n.nodeType === 1 && n.hasAttribute(m[1])) return n;
return null;
}
appendChild(c) { c.parentNode = this; this.childNodes.push(c); return c; }
removeChild(c) {
const i = this.childNodes.indexOf(c);
@@ -66,15 +106,37 @@ function serialize(n) {
if (n.tag === "#text") return n.nodeValue ?? "";
if (n.tag === "#raw") return n.rawHTML ?? "";
const attrs = Object.keys(n.attrs).sort().map((k) => ` ${k}="${n.attrs[k]}"`).join("");
return `<${n.tag}${attrs}>${n.childNodes.map(serialize).join("")}</${n.tag}>`;
const style = n.style.cssText ? ` style="${n.style.cssText}"` : "";
return `<${n.tag}${attrs}${style}>${n.childNodes.map(serialize).join("")}</${n.tag}>`;
}
const body = new DNode("body");
const documentElement = new DNode("html");
globalThis.document = {
createElement: (tag) => new DNode(tag),
body,
documentElement,
createElement: (tag) => new DNode(tag, XHTML_NS),
createElementNS: (ns, tag) => new DNode(tag, ns),
createTextNode: (text) => { const n = new DNode("#text"); n.nodeValue = text; return n; },
getElementById: () => null,
querySelector: () => null,
addEventListener: () => {},
removeEventListener: () => {},
};
// Viewport size the floating engine collides against. Tests override these.
globalThis.innerWidth = 1024;
globalThis.innerHeight = 768;
globalThis.addEventListener ??= () => {};
globalThis.removeEventListener ??= () => {};
globalThis.requestAnimationFrame = (fn) => setTimeout(() => fn(0), 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
globalThis.getComputedStyle = (el) => ({
getPropertyValue: (k) => el.style.getPropertyValue(k),
fontSize: "16px",
});
// ---- go_js_wasm_exec boilerplate ----
globalThis.require = require;
globalThis.fs = require("fs");