refactor folder structure
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
package wasmruntime
|
||||
|
||||
import "kjol/vdom"
|
||||
import "kjol/wasmruntime/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,
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"syscall/js"
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// Run from kjol/go:
|
||||
|
||||
@@ -5,7 +5,7 @@ package wasmruntime
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// The browser half of the host API (see host.go for the contract and the two
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"syscall/js"
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// buildServerDOM fakes what the HTML parser hands us: a real DOM tree, built from
|
||||
|
||||
@@ -5,7 +5,7 @@ package wasmruntime
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -5,7 +5,7 @@ package wasmruntime
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// State preservation across an in-place hot swap (see the dev server's
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"syscall/js"
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// Run from kjol/go:
|
||||
|
||||
@@ -8,7 +8,7 @@ package wasmruntime
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
var document = js.Global().Get("document")
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// The reconciler is wasm-only and needs a DOM, so these do not run under a plain
|
||||
|
||||
@@ -5,7 +5,7 @@ package wasmruntime
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// Router holds the current path in a signal, so reading Path() during render
|
||||
|
||||
93
go/wasmruntime/rsc/client_wasm.go
Normal file
93
go/wasmruntime/rsc/client_wasm.go
Normal file
@@ -0,0 +1,93 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package rsc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"syscall/js"
|
||||
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// Mount connects to a server component by name and returns a render function.
|
||||
// The generated client stub for a //gowasm:server component just calls this, so
|
||||
// the call site is identical to a client component. It POSTs {name, state,
|
||||
// event} to /rsc and swaps (reconciles) the returned render into the DOM. State
|
||||
// is opaque and round-trips through the client — the server keeps nothing.
|
||||
func Mount(name string) func() *vdom.VNode {
|
||||
tree := vdom.NewSignal[*vdom.VNode](nil)
|
||||
var state [][]byte
|
||||
var post func(kind string, nodeID int, event, value string)
|
||||
|
||||
var convert func(*SNode) *vdom.VNode
|
||||
convert = func(sn *SNode) *vdom.VNode {
|
||||
if sn == nil {
|
||||
return nil
|
||||
}
|
||||
if sn.Tag == "" {
|
||||
return vdom.Text(sn.Text)
|
||||
}
|
||||
mods := make([]vdom.Mod, 0, len(sn.Attrs)+len(sn.Events)+len(sn.Kids)+1)
|
||||
for k, v := range sn.Attrs {
|
||||
mods = append(mods, vdom.Attr(k, v))
|
||||
}
|
||||
if sn.HTML != "" {
|
||||
mods = append(mods, vdom.Raw(sn.HTML))
|
||||
}
|
||||
for _, ev := range sn.Events {
|
||||
event, id := ev, sn.ID
|
||||
mods = append(mods, vdom.OnEvent(ev, func(e vdom.Event) { post("event", id, event, e.Value()) }))
|
||||
}
|
||||
for _, k := range sn.Kids {
|
||||
if c := convert(k); c != nil {
|
||||
mods = append(mods, c)
|
||||
}
|
||||
}
|
||||
return vdom.El(sn.Tag, mods...)
|
||||
}
|
||||
|
||||
onArr := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
arr := js.Global().Get("Uint8Array").New(args[0])
|
||||
b := make([]byte, arr.Get("length").Int())
|
||||
js.CopyBytesToGo(b, arr)
|
||||
var resp Response
|
||||
if gob.NewDecoder(bytes.NewReader(b)).Decode(&resp) == nil {
|
||||
state = resp.State
|
||||
tree.Set(convert(resp.Tree)) // re-render -> reconcile into the DOM
|
||||
}
|
||||
return nil
|
||||
})
|
||||
onResp := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
args[0].Call("arrayBuffer").Call("then", onArr)
|
||||
return nil
|
||||
})
|
||||
|
||||
loc := js.Global().Get("location")
|
||||
url := loc.Get("protocol").String() + "//" + loc.Get("host").String() + "/rsc"
|
||||
|
||||
post = func(kind string, nodeID int, event, value string) {
|
||||
var buf bytes.Buffer
|
||||
if gob.NewEncoder(&buf).Encode(Request{Kind: kind, Name: name, State: state, NodeID: nodeID, Event: event, Value: value}) != nil {
|
||||
return
|
||||
}
|
||||
body := js.Global().Get("Uint8Array").New(buf.Len())
|
||||
js.CopyBytesToJS(body, buf.Bytes())
|
||||
opts := js.Global().Get("Object").New()
|
||||
opts.Set("method", "POST")
|
||||
opts.Set("body", body)
|
||||
js.Global().Call("fetch", url, opts).Call("then", onResp)
|
||||
}
|
||||
|
||||
started := false
|
||||
return func() *vdom.VNode {
|
||||
if !started {
|
||||
started = true
|
||||
post("mount", 0, "", "")
|
||||
}
|
||||
if t := tree.Get(); t != nil {
|
||||
return t
|
||||
}
|
||||
return vdom.Div(vdom.Attr("class", "text-muted"), vdom.Text("loading server component…"))
|
||||
}
|
||||
}
|
||||
38
go/wasmruntime/rsc/proto.go
Normal file
38
go/wasmruntime/rsc/proto.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Package rsc runs server components with a stateless, React/Next-style
|
||||
// request/response: the client POSTs the component name, its (opaque) signal
|
||||
// state, and any triggered event; the server restores the state, applies the
|
||||
// event, re-renders, and returns the new state + rendered tree, which the client
|
||||
// reconciles ("swaps") into the DOM. No persistent connection, no server-held
|
||||
// session — state round-trips through the client.
|
||||
//
|
||||
// This file holds the wire types (gob), shared by server and client.
|
||||
package rsc
|
||||
|
||||
// SNode is a serialized render node. Nodes with server handlers carry an ID and
|
||||
// their event names; the client sends {name, state, ID, event} back so the
|
||||
// server can find and invoke the handler.
|
||||
type SNode struct {
|
||||
ID int
|
||||
Tag string
|
||||
Text string
|
||||
HTML string
|
||||
Attrs map[string]string
|
||||
Events []string
|
||||
Kids []*SNode
|
||||
}
|
||||
|
||||
// Request is client -> server (every request is self-contained).
|
||||
type Request struct {
|
||||
Kind string // "mount" | "event"
|
||||
Name string // component name
|
||||
State [][]byte // opaque signal snapshot from the previous response
|
||||
NodeID int // event: node that fired
|
||||
Event string // event: event name
|
||||
Value string // event: target value (inputs)
|
||||
}
|
||||
|
||||
// Response is server -> client.
|
||||
type Response struct {
|
||||
State [][]byte
|
||||
Tree *SNode
|
||||
}
|
||||
107
go/wasmruntime/rsc/server.go
Normal file
107
go/wasmruntime/rsc/server.go
Normal file
@@ -0,0 +1,107 @@
|
||||
//go:build !(js && wasm)
|
||||
|
||||
package rsc
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// registry maps a server-component name to its factory (created by generated
|
||||
// code — one Register per //gowasm:server component). The factory builds the
|
||||
// component's signals + render closure; here it's re-run per request (stateless).
|
||||
var registry = map[string]func() func() *vdom.VNode{}
|
||||
|
||||
func Register(name string, factory func() func() *vdom.VNode) { registry[name] = factory }
|
||||
|
||||
// renderMu serializes server renders because the vdom signal Collector is a
|
||||
// process-global (see vdom.BeginCollect). Fine for this scale.
|
||||
var renderMu sync.Mutex
|
||||
|
||||
// Handler is the single /rsc endpoint. It restores the component's signals from
|
||||
// the request, applies the event (if any), re-renders, and returns the new
|
||||
// signal snapshot + tree.
|
||||
func Handler(w http.ResponseWriter, r *http.Request) {
|
||||
var req Request
|
||||
if gob.NewDecoder(r.Body).Decode(&req) != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
factory := registry[req.Name]
|
||||
if factory == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
renderMu.Lock()
|
||||
c := vdom.BeginCollect(req.State) // restore prior signal values (nil on mount)
|
||||
render := factory() // signals created + restored here
|
||||
vdom.EndCollect()
|
||||
|
||||
if req.Kind == "event" {
|
||||
// Reproduce the tree the client currently shows (same state) to find the
|
||||
// handler by node id, then invoke it (mutating signals).
|
||||
_, handlers := renderIDs(render())
|
||||
if hm := handlers[req.NodeID]; hm != nil {
|
||||
if h := hm[req.Event]; h != nil {
|
||||
h(serverEvent{value: req.Value})
|
||||
}
|
||||
}
|
||||
}
|
||||
tree, _ := renderIDs(render()) // render the result (reflects the mutation)
|
||||
resp := Response{State: c.Snapshot(), Tree: tree}
|
||||
renderMu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_ = gob.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// renderIDs serializes a VNode tree to SNode, assigning a stable (traversal
|
||||
// order) id to each node with handlers and building the handler table.
|
||||
func renderIDs(root *vdom.VNode) (*SNode, map[int]map[string]func(vdom.Event)) {
|
||||
handlers := map[int]map[string]func(vdom.Event){}
|
||||
id := 0
|
||||
var walk func(*vdom.VNode) *SNode
|
||||
walk = func(v *vdom.VNode) *SNode {
|
||||
sn := &SNode{Tag: v.Tag, Text: v.Text, HTML: v.HTML, Attrs: v.Attrs}
|
||||
if len(v.Events) > 0 {
|
||||
id++
|
||||
sn.ID = id
|
||||
hm := map[string]func(vdom.Event){}
|
||||
for ev, h := range v.Events {
|
||||
sn.Events = append(sn.Events, ev)
|
||||
hm[ev] = h
|
||||
}
|
||||
sort.Strings(sn.Events)
|
||||
handlers[id] = hm
|
||||
}
|
||||
if v.HTML == "" {
|
||||
for _, k := range v.Children {
|
||||
sn.Kids = append(sn.Kids, walk(k))
|
||||
}
|
||||
}
|
||||
return sn
|
||||
}
|
||||
return walk(root), handlers
|
||||
}
|
||||
|
||||
// serverEvent is the vdom.Event a server component sees when the client replays a
|
||||
// handler invocation. Only the target's value survives the round trip (it is all
|
||||
// the client sends); everything else — key, pointer coordinates, the DOM target,
|
||||
// the DataTransfer — is meaningless on the server and reads as its zero value.
|
||||
type serverEvent struct{ value string }
|
||||
|
||||
func (e serverEvent) PreventDefault() {}
|
||||
func (e serverEvent) StopPropagation() {}
|
||||
func (e serverEvent) Value() string { return e.value }
|
||||
func (e serverEvent) Checked() bool { return false }
|
||||
func (e serverEvent) Key() string { return "" }
|
||||
func (e serverEvent) ClientX() int { return 0 }
|
||||
func (e serverEvent) ClientY() int { return 0 }
|
||||
func (e serverEvent) Target() any { return nil }
|
||||
func (e serverEvent) SetData(_, _ string) {}
|
||||
func (e serverEvent) GetData(string) string { return "" }
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// Floating.Panel and Modal both declare their initial inline style in the style
|
||||
|
||||
@@ -5,7 +5,7 @@ package wasmruntime
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// An <svg> built with document.createElement() is NOT an SVG element — it is an
|
||||
|
||||
66
go/wasmruntime/vdom/events.go
Normal file
66
go/wasmruntime/vdom/events.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package vdom
|
||||
|
||||
// DOM event-name constants for On / OnEvent.
|
||||
const (
|
||||
EVENT_CLICK = "click"
|
||||
EVENT_DBLCLICK = "dblclick"
|
||||
EVENT_INPUT = "input"
|
||||
EVENT_CHANGE = "change"
|
||||
EVENT_SUBMIT = "submit"
|
||||
EVENT_KEYDOWN = "keydown"
|
||||
EVENT_KEYUP = "keyup"
|
||||
EVENT_FOCUS = "focus"
|
||||
EVENT_BLUR = "blur"
|
||||
EVENT_MOUSEDOWN = "mousedown"
|
||||
EVENT_MOUSEUP = "mouseup"
|
||||
EVENT_MOUSEMOVE = "mousemove"
|
||||
EVENT_MOUSEENTER = "mouseenter"
|
||||
EVENT_MOUSELEAVE = "mouseleave"
|
||||
EVENT_CONTEXTMENU = "contextmenu"
|
||||
|
||||
// Pointer events unify mouse, touch and pen behind one set of handlers that all
|
||||
// carry clientX/clientY. A component that wants to be drawn on — the signature pad —
|
||||
// needs exactly one code path, not a mouse one and a touch one that drift apart.
|
||||
EVENT_POINTERDOWN = "pointerdown"
|
||||
EVENT_POINTERMOVE = "pointermove"
|
||||
EVENT_POINTERUP = "pointerup"
|
||||
EVENT_POINTERCANCEL = "pointercancel"
|
||||
EVENT_POINTERLEAVE = "pointerleave"
|
||||
|
||||
EVENT_SCROLL = "scroll"
|
||||
EVENT_RESIZE = "resize"
|
||||
EVENT_WHEEL = "wheel"
|
||||
|
||||
// focusin/focusout BUBBLE; focus/blur do not. Anything that needs to know a
|
||||
// subtree gained focus (a tooltip staying open while a child is focused) must
|
||||
// use these.
|
||||
EVENT_FOCUSIN = "focusin"
|
||||
EVENT_FOCUSOUT = "focusout"
|
||||
|
||||
// HTML5 drag-and-drop. The handler's Event carries the DataTransfer via
|
||||
// SetData / GetData.
|
||||
EVENT_DRAGSTART = "dragstart"
|
||||
EVENT_DRAGOVER = "dragover"
|
||||
EVENT_DRAGENTER = "dragenter"
|
||||
EVENT_DRAGLEAVE = "dragleave"
|
||||
EVENT_DROP = "drop"
|
||||
EVENT_DRAGEND = "dragend"
|
||||
)
|
||||
|
||||
// KeyboardEvent.key values, for Event.Key().
|
||||
const (
|
||||
KEY_ESCAPE = "Escape"
|
||||
KEY_ENTER = "Enter"
|
||||
KEY_SPACE = " "
|
||||
KEY_TAB = "Tab"
|
||||
KEY_BACKSPACE = "Backspace"
|
||||
KEY_DELETE = "Delete"
|
||||
KEY_ARROW_UP = "ArrowUp"
|
||||
KEY_ARROW_DOWN = "ArrowDown"
|
||||
KEY_ARROW_LEFT = "ArrowLeft"
|
||||
KEY_ARROW_RIGHT = "ArrowRight"
|
||||
KEY_HOME = "Home"
|
||||
KEY_END = "End"
|
||||
KEY_PAGE_UP = "PageUp"
|
||||
KEY_PAGE_DOWN = "PageDown"
|
||||
)
|
||||
7
go/wasmruntime/vdom/mode_native.go
Normal file
7
go/wasmruntime/vdom/mode_native.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !(js && wasm)
|
||||
|
||||
package vdom
|
||||
|
||||
// IsClient is false on the server (native). Components use it to guard
|
||||
// client-only effects (e.g. fetching) so they don't run during SSR.
|
||||
const IsClient = false
|
||||
6
go/wasmruntime/vdom/mode_wasm.go
Normal file
6
go/wasmruntime/vdom/mode_wasm.go
Normal file
@@ -0,0 +1,6 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package vdom
|
||||
|
||||
// IsClient is true in the browser (wasm).
|
||||
const IsClient = true
|
||||
55
go/wasmruntime/vdom/ref.go
Normal file
55
go/wasmruntime/vdom/ref.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package vdom
|
||||
|
||||
// Ref is a handle on the real DOM node a VNode rendered to. It is the bridge the
|
||||
// neutral component code uses to ask the browser for things it cannot know on its
|
||||
// own — how big an element is, where it sits in the viewport — without importing
|
||||
// anything platform-specific: the value inside is opaque (`any`), filled in by the
|
||||
// wasm reconciler and always nil on the server. Read it through the wasmruntime
|
||||
// host API (wasmruntime.Rect, SetStyle, Focus, …), which no-ops when it is nil.
|
||||
//
|
||||
// A component creates one ref per element it needs to measure and keeps it across
|
||||
// renders (in a closure, next to its signals — NOT rebuilt inside the render
|
||||
// function, or it would be a fresh empty ref every frame):
|
||||
//
|
||||
// panel := vdom.NewRef()
|
||||
// return func() *VNode {
|
||||
// return Div(WithRef(panel), Attr("class", "…"), …)
|
||||
// }
|
||||
//
|
||||
// and later, after the DOM exists (see wasmruntime.AfterRender):
|
||||
//
|
||||
// r := wasmruntime.Rect(panel) // zero Rect if unmounted or on the server
|
||||
type Ref struct{ node any }
|
||||
|
||||
// NewRef returns an unattached Ref.
|
||||
func NewRef() *Ref { return &Ref{} }
|
||||
|
||||
// Node is the platform DOM handle (a js.Value in the browser), or nil if the ref
|
||||
// is not currently attached to a mounted element. Component code should not need
|
||||
// this — it is for the host API and the reconciler.
|
||||
func (r *Ref) Node() any {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.node
|
||||
}
|
||||
|
||||
// Mounted reports whether the ref currently points at a live DOM node.
|
||||
func (r *Ref) Mounted() bool { return r != nil && r.node != nil }
|
||||
|
||||
type refMod struct{ ref *Ref }
|
||||
|
||||
func (m refMod) apply(n *VNode) { n.Ref = m.ref }
|
||||
|
||||
// WithRef attaches a Ref to this element, so the DOM node it renders to can be
|
||||
// measured and manipulated. Ignored on the server.
|
||||
func WithRef(r *Ref) Mod { return refMod{r} }
|
||||
|
||||
// SetRefNode points a Ref at a platform DOM handle (or nil to detach). It exists
|
||||
// for the reconciler and the host API, which live in another package; component
|
||||
// code has no reason to call it.
|
||||
func SetRefNode(r *Ref, node any) {
|
||||
if r != nil {
|
||||
r.node = node
|
||||
}
|
||||
}
|
||||
81
go/wasmruntime/vdom/signal.go
Normal file
81
go/wasmruntime/vdom/signal.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package vdom
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Schedule is installed by the wasm runtime at startup; a signal write
|
||||
// triggers a re-render. On the server it is nil (renders are one-shot).
|
||||
var Schedule func()
|
||||
|
||||
// Signal holds state. On the client, writing it schedules a re-render.
|
||||
type Signal[T any] struct{ v T }
|
||||
|
||||
func NewSignal[T any](initial T) *Signal[T] {
|
||||
s := &Signal[T]{v: initial}
|
||||
if active != nil {
|
||||
active.adopt(s) // server component round-trip: restore prior value, track for snapshot
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Signal[T]) Get() T { return s.v }
|
||||
|
||||
func (s *Signal[T]) Set(v T) {
|
||||
s.v = v
|
||||
if Schedule != nil {
|
||||
Schedule()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Signal[T]) Update(fn func(T) T) { s.Set(fn(s.v)) }
|
||||
|
||||
func (s *Signal[T]) snapshot() []byte { b, _ := json.Marshal(s.v); return b }
|
||||
func (s *Signal[T]) restoreFrom(b []byte) { _ = json.Unmarshal(b, &s.v) }
|
||||
|
||||
// ---- signal-state round-trip for server components ----
|
||||
//
|
||||
// A server component is stateless on the server: its signal values are
|
||||
// serialized and round-tripped through the client. While a Collector is active
|
||||
// (during a server-component render on the server), NewSignal restores each
|
||||
// signal's value from the incoming snapshot by creation order (hook-order), and
|
||||
// remembers it so the new values can be snapshotted back out.
|
||||
|
||||
type signalState interface {
|
||||
snapshot() []byte
|
||||
restoreFrom([]byte)
|
||||
}
|
||||
|
||||
// Collector captures the signals created during a server render.
|
||||
type Collector struct {
|
||||
restore [][]byte
|
||||
idx int
|
||||
sigs []signalState
|
||||
}
|
||||
|
||||
var active *Collector
|
||||
|
||||
func (c *Collector) adopt(s signalState) {
|
||||
if c.idx < len(c.restore) {
|
||||
s.restoreFrom(c.restore[c.idx])
|
||||
}
|
||||
c.idx++
|
||||
c.sigs = append(c.sigs, s)
|
||||
}
|
||||
|
||||
// BeginCollect starts collecting signals, restoring them from `restore` (which
|
||||
// may be nil for an initial mount). Call EndCollect when construction is done.
|
||||
func BeginCollect(restore [][]byte) *Collector {
|
||||
active = &Collector{restore: restore}
|
||||
return active
|
||||
}
|
||||
|
||||
// EndCollect stops collecting (rendering/handlers may still read the signals).
|
||||
func EndCollect() { active = nil }
|
||||
|
||||
// Snapshot returns the current values of the collected signals, in order.
|
||||
func (c *Collector) Snapshot() [][]byte {
|
||||
out := make([][]byte, len(c.sigs))
|
||||
for i, s := range c.sigs {
|
||||
out[i] = s.snapshot()
|
||||
}
|
||||
return out
|
||||
}
|
||||
81
go/wasmruntime/vdom/tags.go
Normal file
81
go/wasmruntime/vdom/tags.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package vdom
|
||||
|
||||
// HTML tag helpers over El.
|
||||
//
|
||||
// The set covers every tag kjol/webui actually renders, which is the bar that
|
||||
// matters: a partial set is worse than none, because it forces a half-and-half style
|
||||
// where `Div(...)` sits next to `El("thead", ...)` and the reader has to know which
|
||||
// tags happen to be covered. If you reach for a tag that is not here, add it rather
|
||||
// than falling back to El.
|
||||
//
|
||||
// El itself stays exported for the genuinely dynamic case — a tag chosen at runtime,
|
||||
// as a component's `Tag` prop does.
|
||||
|
||||
// --- structure ---
|
||||
func Div(m ...Mod) *VNode { return El("div", m...) }
|
||||
func Span(m ...Mod) *VNode { return El("span", m...) }
|
||||
func P(m ...Mod) *VNode { return El("p", m...) }
|
||||
func Section(m ...Mod) *VNode { return El("section", m...) }
|
||||
func Article(m ...Mod) *VNode { return El("article", m...) }
|
||||
func Aside(m ...Mod) *VNode { return El("aside", m...) }
|
||||
func Nav(m ...Mod) *VNode { return El("nav", m...) }
|
||||
func Header(m ...Mod) *VNode { return El("header", m...) }
|
||||
func Main(m ...Mod) *VNode { return El("main", m...) }
|
||||
func Footer(m ...Mod) *VNode { return El("footer", m...) }
|
||||
func Hr(m ...Mod) *VNode { return El("hr", m...) }
|
||||
func Br(m ...Mod) *VNode { return El("br", m...) }
|
||||
|
||||
// --- headings ---
|
||||
func H1(m ...Mod) *VNode { return El("h1", m...) }
|
||||
func H2(m ...Mod) *VNode { return El("h2", m...) }
|
||||
func H3(m ...Mod) *VNode { return El("h3", m...) }
|
||||
func H4(m ...Mod) *VNode { return El("h4", m...) }
|
||||
func H5(m ...Mod) *VNode { return El("h5", m...) }
|
||||
func H6(m ...Mod) *VNode { return El("h6", m...) }
|
||||
|
||||
// --- inline ---
|
||||
func A(m ...Mod) *VNode { return El("a", m...) }
|
||||
func Strong(m ...Mod) *VNode { return El("strong", m...) }
|
||||
func B(m ...Mod) *VNode { return El("b", m...) }
|
||||
func Em(m ...Mod) *VNode { return El("em", m...) }
|
||||
func I(m ...Mod) *VNode { return El("i", m...) }
|
||||
func Small(m ...Mod) *VNode { return El("small", m...) }
|
||||
func Code(m ...Mod) *VNode { return El("code", m...) }
|
||||
func Pre(m ...Mod) *VNode { return El("pre", m...) }
|
||||
|
||||
// --- lists ---
|
||||
func Ul(m ...Mod) *VNode { return El("ul", m...) }
|
||||
func Ol(m ...Mod) *VNode { return El("ol", m...) }
|
||||
func Li(m ...Mod) *VNode { return El("li", m...) }
|
||||
|
||||
// --- forms ---
|
||||
func Form(m ...Mod) *VNode { return El("form", m...) }
|
||||
func Fieldset(m ...Mod) *VNode { return El("fieldset", m...) }
|
||||
func Legend(m ...Mod) *VNode { return El("legend", m...) }
|
||||
func Label(m ...Mod) *VNode { return El("label", m...) }
|
||||
func Input(m ...Mod) *VNode { return El("input", m...) }
|
||||
func Textarea(m ...Mod) *VNode { return El("textarea", m...) }
|
||||
func Select(m ...Mod) *VNode { return El("select", m...) }
|
||||
func Option(m ...Mod) *VNode { return El("option", m...) }
|
||||
func Button(m ...Mod) *VNode { return El("button", m...) }
|
||||
|
||||
// --- tables ---
|
||||
func Table(m ...Mod) *VNode { return El("table", m...) }
|
||||
func Thead(m ...Mod) *VNode { return El("thead", m...) }
|
||||
func Tbody(m ...Mod) *VNode { return El("tbody", m...) }
|
||||
func Tfoot(m ...Mod) *VNode { return El("tfoot", m...) }
|
||||
func Tr(m ...Mod) *VNode { return El("tr", m...) }
|
||||
func Th(m ...Mod) *VNode { return El("th", m...) }
|
||||
func Td(m ...Mod) *VNode { return El("td", m...) }
|
||||
func Caption(m ...Mod) *VNode { return El("caption", m...) }
|
||||
|
||||
// --- media and misc ---
|
||||
func Img(m ...Mod) *VNode { return El("img", m...) }
|
||||
func Canvas(m ...Mod) *VNode { return El("canvas", m...) }
|
||||
func Dialog(m ...Mod) *VNode { return El("dialog", m...) }
|
||||
|
||||
// Svg and Path are the two SVG tags the icon layer needs. The reconciler creates
|
||||
// them in the SVG namespace (see wasmruntime): an <svg> built as ordinary HTML is an
|
||||
// inert unknown element that renders nothing.
|
||||
func Svg(m ...Mod) *VNode { return El("svg", m...) }
|
||||
func Path(m ...Mod) *VNode { return El("path", m...) }
|
||||
244
go/wasmruntime/vdom/vnode.go
Normal file
244
go/wasmruntime/vdom/vnode.go
Normal file
@@ -0,0 +1,244 @@
|
||||
// Package vdom is a platform-neutral virtual DOM shared by the server (renders
|
||||
// to an HTML string) and the client (reconciles into the real DOM). It compiles
|
||||
// on BOTH native and js/wasm, so the same component code runs in both places —
|
||||
// which is what makes server-side rendering + client hydration possible.
|
||||
package vdom
|
||||
|
||||
import (
|
||||
"html"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Event is a DOM event passed to handlers. The client provides a concrete
|
||||
// implementation; on the server events are never invoked, and every accessor
|
||||
// returns its zero value.
|
||||
type Event interface {
|
||||
PreventDefault()
|
||||
StopPropagation()
|
||||
Value() string // target.value (for inputs)
|
||||
Checked() bool // target.checked (for checkboxes / radios)
|
||||
Key() string // KeyboardEvent.key — compare against the KEY_* constants
|
||||
ClientX() int // pointer position, viewport coordinates
|
||||
ClientY() int //
|
||||
Target() any // the platform DOM handle of event.target; nil on the server
|
||||
SetData(format, data string) // DataTransfer — no-ops off a drag event
|
||||
GetData(format string) string
|
||||
}
|
||||
|
||||
// VNode is a virtual DOM node. Tag == "" is a text node (content in Text). HTML,
|
||||
// if set on an element, is raw innerHTML (children ignored). Tag == TagPortal
|
||||
// renders its children into document.body instead of in place.
|
||||
type VNode struct {
|
||||
Tag string
|
||||
Text string
|
||||
HTML string
|
||||
Attrs map[string]string
|
||||
Props map[string]string
|
||||
Events map[string]func(Event)
|
||||
Children []*VNode
|
||||
|
||||
// Ref, if set, receives the DOM node this VNode renders to (see WithRef).
|
||||
Ref *Ref
|
||||
|
||||
// Runtime holds the wasm reconciler's per-node bookkeeping (DOM handle,
|
||||
// listener wrappers). It's `any` so this package stays platform-neutral; it
|
||||
// is nil on the server.
|
||||
Runtime any
|
||||
}
|
||||
|
||||
// Mod configures a VNode while it is built.
|
||||
type Mod interface{ apply(*VNode) }
|
||||
|
||||
// TagPortal marks a node whose children are mounted into document.body rather
|
||||
// than into its own parent. It is how a floating panel escapes an ancestor's
|
||||
// `overflow: hidden` (menus scroll, modals clip) or `transform` (which would
|
||||
// re-root `position: fixed` onto the modal instead of the viewport). In the tree
|
||||
// it occupies a hidden, zero-size placeholder; the children live at body level.
|
||||
const TagPortal = "#portal"
|
||||
|
||||
// El builds an element VNode.
|
||||
func El(tag string, mods ...Mod) *VNode {
|
||||
n := &VNode{Tag: tag, Attrs: map[string]string{}, Props: map[string]string{}, Events: map[string]func(Event){}}
|
||||
for _, m := range mods {
|
||||
m.apply(n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Portal renders its children into document.body. On the server it renders the
|
||||
// placeholder only — floating content is closed during SSR, so there is nothing
|
||||
// to emit, and the placeholder keeps the client's hydration walk aligned.
|
||||
func Portal(children ...*VNode) *VNode {
|
||||
n := El(TagPortal)
|
||||
for _, c := range children {
|
||||
c.apply(n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Text builds a text VNode.
|
||||
func Text(s string) *VNode { return &VNode{Text: s} }
|
||||
|
||||
func (n *VNode) apply(parent *VNode) {
|
||||
if n != nil { // a nil child renders nothing (e.g. a closed Modal/Menu returns nil)
|
||||
parent.Children = append(parent.Children, n)
|
||||
}
|
||||
}
|
||||
|
||||
type attrMod struct{ k, v string }
|
||||
|
||||
func (a attrMod) apply(n *VNode) { n.Attrs[a.k] = a.v }
|
||||
|
||||
// Attr sets an HTML attribute.
|
||||
func Attr(k, v string) Mod { return attrMod{k, v} }
|
||||
|
||||
type propMod struct{ k, v string }
|
||||
|
||||
func (p propMod) apply(n *VNode) { n.Props[p.k] = p.v }
|
||||
|
||||
// Prop sets a live DOM property (e.g. an input's value).
|
||||
func Prop(k, v string) Mod { return propMod{k, v} }
|
||||
|
||||
// BoolProp sets a boolean DOM property: checked, disabled, open, and the rest of
|
||||
// boolProps below.
|
||||
//
|
||||
// Use this rather than Prop for those — a boolean written as a string is wrong in BOTH
|
||||
// directions, and wrong in the same direction both times, which is what makes it such a
|
||||
// good hiding place for a bug. In HTML a boolean attribute is presence-based, so
|
||||
// `checked="false"` renders a TICKED box. In JS every non-empty string is truthy, so
|
||||
// `el.checked = "false"` also ticks it. A checkbox bound with Prop is therefore ticked
|
||||
// forever, and looks fine until you try to untick it.
|
||||
func BoolProp(k string, on bool) Mod { return propMod{k, strconv.FormatBool(on)} }
|
||||
|
||||
// boolProps are the DOM properties whose type is boolean, so both the reconciler and
|
||||
// SSR can special-case them (see BoolProp for why they must).
|
||||
//
|
||||
// This is a name table because the DOM is a name table: there is no way to ask, of a
|
||||
// VNode alone, whether `checked` on this tag is a boolean. Every framework carries the
|
||||
// same list.
|
||||
var boolProps = map[string]bool{
|
||||
"checked": true,
|
||||
"disabled": true,
|
||||
"readOnly": true,
|
||||
"required": true,
|
||||
"selected": true,
|
||||
"multiple": true,
|
||||
"hidden": true,
|
||||
"open": true,
|
||||
"autofocus": true,
|
||||
"indeterminate": true,
|
||||
"defaultChecked": true,
|
||||
}
|
||||
|
||||
// IsBoolProp reports whether a prop name is a boolean DOM property. The reconciler
|
||||
// needs it to write a real bool instead of a string.
|
||||
func IsBoolProp(k string) bool { return boolProps[k] }
|
||||
|
||||
type htmlMod struct{ html string }
|
||||
|
||||
func (h htmlMod) apply(n *VNode) { n.HTML = h.html }
|
||||
|
||||
// Raw sets inner HTML verbatim (children ignored) — e.g. a server-computed SVG.
|
||||
func Raw(html string) Mod { return htmlMod{html} }
|
||||
|
||||
type eventMod struct {
|
||||
name string
|
||||
h func(Event)
|
||||
}
|
||||
|
||||
func (e eventMod) apply(n *VNode) { n.Events[e.name] = e.h }
|
||||
|
||||
// On registers an event handler that ignores the event object.
|
||||
func On(event string, h func()) Mod { return eventMod{event, func(Event) { h() }} }
|
||||
|
||||
// OnEvent registers an event handler that receives the Event.
|
||||
func OnEvent(event string, h func(Event)) Mod { return eventMod{event, h} }
|
||||
|
||||
// --- server-side HTML rendering (used for SSR; runs on any platform) ---
|
||||
|
||||
var voidTags = map[string]bool{"br": true, "hr": true, "img": true, "input": true, "meta": true, "link": true}
|
||||
|
||||
// RenderHTML serializes a VNode tree to HTML with NO extra whitespace, so the
|
||||
// browser's parsed childNodes line up 1:1 with the VNode children on hydration.
|
||||
func RenderHTML(n *VNode) string {
|
||||
var b strings.Builder
|
||||
writeNode(&b, n)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeNode(b *strings.Builder, n *VNode) {
|
||||
if n.Tag == "" {
|
||||
b.WriteString(html.EscapeString(n.Text))
|
||||
return
|
||||
}
|
||||
if n.Tag == TagPortal {
|
||||
// Placeholder only — the children belong to document.body, which SSR does
|
||||
// not own. Emitting the same element the client creates keeps hydration's
|
||||
// childNodes lined up.
|
||||
b.WriteString(`<div data-portal="" style="display:none"></div>`)
|
||||
return
|
||||
}
|
||||
b.WriteByte('<')
|
||||
b.WriteString(n.Tag)
|
||||
writeAttrs(b, n.Attrs)
|
||||
writeProps(b, n.Props) // props like an input's value show up as attributes in SSR
|
||||
b.WriteByte('>')
|
||||
if voidTags[n.Tag] {
|
||||
return
|
||||
}
|
||||
if n.HTML != "" {
|
||||
b.WriteString(n.HTML) // raw
|
||||
} else {
|
||||
for _, c := range n.Children {
|
||||
writeNode(b, c)
|
||||
}
|
||||
}
|
||||
b.WriteString("</")
|
||||
b.WriteString(n.Tag)
|
||||
b.WriteByte('>')
|
||||
}
|
||||
|
||||
func writeAttrs(b *strings.Builder, m map[string]string) {
|
||||
for _, k := range sortedKeys(m) {
|
||||
writeAttr(b, k, m[k])
|
||||
}
|
||||
}
|
||||
|
||||
// writeProps serializes live DOM properties as HTML attributes for SSR, so the
|
||||
// server's markup shows what the client's props will hold.
|
||||
//
|
||||
// A boolean prop is emitted as a BARE attribute when true and omitted entirely when
|
||||
// false — that is what the HTML boolean-attribute rule means. Writing checked="false"
|
||||
// would render a ticked checkbox, which is the exact opposite of what was asked for.
|
||||
func writeProps(b *strings.Builder, m map[string]string) {
|
||||
for _, k := range sortedKeys(m) {
|
||||
v := m[k]
|
||||
if boolProps[k] {
|
||||
if v == "true" {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(strings.ToLower(k))
|
||||
}
|
||||
continue
|
||||
}
|
||||
writeAttr(b, k, v)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAttr(b *strings.Builder, k, v string) {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(k)
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(html.EscapeString(v))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
46
go/wasmruntime/vdom/vnode_test.go
Normal file
46
go/wasmruntime/vdom/vnode_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package vdom
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A boolean DOM property is a trap in HTML: the attribute's PRESENCE is what means
|
||||
// true. checked="false" is a ticked checkbox — the value is not even read. So a false
|
||||
// boolean prop must be omitted entirely, and a true one written bare.
|
||||
func TestBoolPropSSR(t *testing.T) {
|
||||
ticked := RenderHTML(Input(Attr("type", "checkbox"), BoolProp("checked", true)))
|
||||
if !strings.Contains(ticked, " checked") {
|
||||
t.Errorf("a checked box did not render the attribute: %s", ticked)
|
||||
}
|
||||
if strings.Contains(ticked, `checked="`) {
|
||||
t.Errorf("a boolean attribute must be bare, not valued: %s", ticked)
|
||||
}
|
||||
|
||||
unticked := RenderHTML(Input(Attr("type", "checkbox"), BoolProp("checked", false)))
|
||||
if strings.Contains(unticked, "checked") {
|
||||
t.Errorf(`an unchecked box must omit the attribute entirely — checked="false" renders as TICKED: %s`, unticked)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-boolean props keep their values: an input's value is a string, and dropping it
|
||||
// when empty would be just as wrong as writing checked="false".
|
||||
func TestValuePropSSRKeepsItsValue(t *testing.T) {
|
||||
got := RenderHTML(Input(Prop("value", "false")))
|
||||
if !strings.Contains(got, `value="false"`) {
|
||||
t.Errorf(`value="false" is a string, not a boolean, and must survive: %s`, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBoolProp(t *testing.T) {
|
||||
for _, k := range []string{"checked", "disabled", "selected", "open"} {
|
||||
if !IsBoolProp(k) {
|
||||
t.Errorf("%q should be known as a boolean property — the reconciler writes it as a string otherwise", k)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"value", "className", "id"} {
|
||||
if IsBoolProp(k) {
|
||||
t.Errorf("%q is not a boolean property", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user