Add fonts, autotable, autotable examples
This commit is contained in:
92
go/wasmruntime/host.go
Normal file
92
go/wasmruntime/host.go
Normal 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
|
||||
}
|
||||
49
go/wasmruntime/host_native.go
Normal file
49
go/wasmruntime/host_native.go
Normal 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
214
go/wasmruntime/host_test.go
Normal 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
438
go/wasmruntime/host_wasm.go
Normal 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()
|
||||
}
|
||||
80
go/wasmruntime/hydrate_test.go
Normal file
80
go/wasmruntime/hydrate_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
41
go/wasmruntime/style_test.go
Normal file
41
go/wasmruntime/style_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
91
go/wasmruntime/svg_test.go
Normal file
91
go/wasmruntime/svg_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
68
go/wasmruntime/testdata/domexec.js
vendored
68
go/wasmruntime/testdata/domexec.js
vendored
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user